Compare commits
65 Commits
master
...
0c7fec900c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c7fec900c | |||
|
|
7be29b7270 | ||
|
|
113d105fac | ||
|
|
cbdcee039a | ||
|
|
47389bb842 | ||
|
|
8ced53b6af | ||
|
|
efdf58d8f9 | ||
|
|
1bf7e1dc4c | ||
|
|
571592b3dc | ||
|
|
47073f2bf1 | ||
|
|
13a12f5938 | ||
|
|
a2f3a07834 | ||
|
|
8fb997a2a0 | ||
|
|
ff72c83ef0 | ||
|
|
80c434d390 | ||
|
|
f1d0771356 | ||
|
|
2646e295d9 | ||
|
|
45bf604381 | ||
|
|
6fa34069ea | ||
|
|
c338682bf2 | ||
|
|
087317171a | ||
|
|
f08cc59fef | ||
|
|
34b9452c1c | ||
|
|
74dd87f4ea | ||
|
|
6e939884cc | ||
|
|
8c86c1ce76 | ||
|
|
e175eac701 | ||
|
|
7f18fc5553 | ||
|
|
3ff4e8758a | ||
|
|
4ae32fce49 | ||
|
|
1a25214984 | ||
|
|
580c6ec3d3 | ||
|
|
c6db61c258 | ||
|
|
eea6bcac4d | ||
|
|
ef1de00020 | ||
|
|
99f5e089f5 | ||
|
|
8430b4ddf6 | ||
|
|
88ad7cb1ac | ||
|
|
11d23aa10c | ||
|
|
3f05a5099e | ||
|
|
a30c527803 | ||
|
|
10d69f977b | ||
|
|
7f8411e83c | ||
|
|
e89babe223 | ||
|
|
aab613030d | ||
|
|
84bf50032b | ||
|
|
b8ad8a94c9 | ||
|
|
99bdd6a3ec | ||
|
|
64d6c20d06 | ||
|
|
285fc84275 | ||
|
|
821ba95247 | ||
|
|
45c854a8d0 | ||
|
|
54b05ed8da | ||
|
|
0cb85adca0 | ||
|
|
7f57e034e4 | ||
|
|
4f4dde988a | ||
|
|
ba911ad598 | ||
|
|
672225d037 | ||
|
|
39125943ec | ||
|
|
bba8d838b7 | ||
|
|
7fcf3b825d | ||
|
|
64c3ee8395 | ||
| 588ab5832f | |||
| 94e838af43 | |||
| 154e27bcc0 |
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@effect-fc/monorepo",
|
"name": "@effect-fc/monorepo",
|
||||||
"packageManager": "bun@1.3.6",
|
"packageManager": "bun@1.3.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./packages/*"
|
"./packages/*"
|
||||||
@@ -15,12 +15,12 @@
|
|||||||
"clean:modules": "turbo clean:modules && rm -rf node_modules"
|
"clean:modules": "turbo clean:modules && rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.11",
|
"@biomejs/biome": "^2.4.8",
|
||||||
"@effect/language-service": "^0.75.0",
|
"@effect/language-service": "^0.83.1",
|
||||||
"@types/bun": "^1.3.6",
|
"@types/bun": "^1.3.11",
|
||||||
"npm-check-updates": "^19.3.1",
|
"npm-check-updates": "^19.6.5",
|
||||||
"npm-sort": "^0.0.4",
|
"npm-sort": "^0.0.4",
|
||||||
"turbo": "^2.7.5",
|
"turbo": "^2.8.20",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^6.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"@docusaurus/module-type-aliases": "3.9.2",
|
"@docusaurus/module-type-aliases": "3.9.2",
|
||||||
"@docusaurus/tsconfig": "3.9.2",
|
"@docusaurus/tsconfig": "3.9.2",
|
||||||
"@docusaurus/types": "3.9.2",
|
"@docusaurus/types": "3.9.2",
|
||||||
"typescript": "~5.6.2"
|
"typescript": "~6.0.0"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|||||||
@@ -38,11 +38,14 @@
|
|||||||
"clean:modules": "rm -rf node_modules"
|
"clean:modules": "rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@effect/platform-browser": "^0.74.0"
|
"@effect/platform-browser": "^0.76.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"effect": "^3.19.0",
|
"effect": "^3.21.0",
|
||||||
"react": "^19.2.0"
|
"react": "^19.2.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"effect-lens": "^0.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Array, Cause, Chunk, type Context, type Duration, Effect, Equal, Exit, Fiber, flow, Hash, HashMap, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect"
|
import { Array, Cause, Chunk, type Context, type Duration, Effect, Equal, Exit, Fiber, flow, Hash, HashMap, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect"
|
||||||
import type * as React from "react"
|
import type * as React from "react"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "./Component.js"
|
||||||
|
import * as Lens from "./Lens.js"
|
||||||
import * as Mutation from "./Mutation.js"
|
import * as Mutation from "./Mutation.js"
|
||||||
import * as PropertyPath from "./PropertyPath.js"
|
import * as PropertyPath from "./PropertyPath.js"
|
||||||
import * as Result from "./Result.js"
|
import * as Result from "./Result.js"
|
||||||
import * as Subscribable from "./Subscribable.js"
|
import * as Subscribable from "./Subscribable.js"
|
||||||
import * as SubscriptionRef from "./SubscriptionRef.js"
|
import * as SubscriptionRef from "./SubscriptionRef.js"
|
||||||
import * as SubscriptionSubRef from "./SubscriptionSubRef.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
|
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
|
||||||
@@ -26,7 +26,7 @@ extends Pipeable.Pipeable {
|
|||||||
readonly debounce: Option.Option<Duration.DurationInput>
|
readonly debounce: Option.Option<Duration.DurationInput>
|
||||||
|
|
||||||
readonly value: Subscribable.Subscribable<Option.Option<A>>
|
readonly value: Subscribable.Subscribable<Option.Option<A>>
|
||||||
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
|
readonly encodedValue: Lens.Lens<I>
|
||||||
readonly error: Subscribable.Subscribable<Option.Option<ParseResult.ParseError>>
|
readonly error: Subscribable.Subscribable<Option.Option<ParseResult.ParseError>>
|
||||||
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>
|
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>
|
||||||
|
|
||||||
@@ -54,10 +54,10 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
|
|||||||
readonly autosubmit: boolean,
|
readonly autosubmit: boolean,
|
||||||
readonly debounce: Option.Option<Duration.DurationInput>,
|
readonly debounce: Option.Option<Duration.DurationInput>,
|
||||||
|
|
||||||
readonly value: SubscriptionRef.SubscriptionRef<Option.Option<A>>,
|
readonly value: Lens.Lens<Option.Option<A>>,
|
||||||
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
|
readonly encodedValue: Lens.Lens<I>,
|
||||||
readonly error: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
|
readonly error: Lens.Lens<Option.Option<ParseResult.ParseError>>,
|
||||||
readonly validationFiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>,
|
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>,
|
||||||
|
|
||||||
readonly runSemaphore: Effect.Semaphore,
|
readonly runSemaphore: Effect.Semaphore,
|
||||||
readonly fieldCache: Ref.Ref<HashMap.HashMap<FormFieldKey, FormField<unknown, unknown>>>,
|
readonly fieldCache: Ref.Ref<HashMap.HashMap<FormFieldKey, FormField<unknown, unknown>>>,
|
||||||
@@ -99,7 +99,7 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
|
|||||||
Option.isSome(this.debounce) ? Stream.debounce(this.debounce.value) : identity
|
Option.isSome(this.debounce) ? Stream.debounce(this.debounce.value) : identity
|
||||||
),
|
),
|
||||||
|
|
||||||
encodedValue => this.validationFiber.pipe(
|
encodedValue => Lens.get(this.validationFiber).pipe(
|
||||||
Effect.andThen(Option.match({
|
Effect.andThen(Option.match({
|
||||||
onSome: Fiber.interrupt,
|
onSome: Fiber.interrupt,
|
||||||
onNone: () => Effect.void,
|
onNone: () => Effect.void,
|
||||||
@@ -110,18 +110,18 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
|
|||||||
exit => Effect.andThen(
|
exit => Effect.andThen(
|
||||||
Exit.matchEffect(exit, {
|
Exit.matchEffect(exit, {
|
||||||
onSuccess: v => Effect.andThen(
|
onSuccess: v => Effect.andThen(
|
||||||
Ref.set(this.value, Option.some(v)),
|
Lens.set(this.value, Option.some(v)),
|
||||||
Ref.set(this.error, Option.none()),
|
Lens.set(this.error, Option.none()),
|
||||||
),
|
),
|
||||||
onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), {
|
onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), {
|
||||||
onSome: e => Ref.set(this.error, Option.some(e)),
|
onSome: e => Lens.set(this.error, Option.some(e)),
|
||||||
onNone: () => Effect.void,
|
onNone: () => Effect.void,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
Ref.set(this.validationFiber, Option.none()),
|
Lens.set(this.validationFiber, Option.none()),
|
||||||
),
|
),
|
||||||
)).pipe(
|
)).pipe(
|
||||||
Effect.tap(fiber => Ref.set(this.validationFiber, Option.some(fiber))),
|
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
|
||||||
Effect.andThen(Fiber.join),
|
Effect.andThen(Fiber.join),
|
||||||
Effect.andThen(value => this.autosubmit
|
Effect.andThen(value => this.autosubmit
|
||||||
? Effect.asVoid(Effect.forkScoped(this.submitValue(value)))
|
? Effect.asVoid(Effect.forkScoped(this.submitValue(value)))
|
||||||
@@ -136,7 +136,7 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException> {
|
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException> {
|
||||||
return this.value.pipe(
|
return Lens.get(this.value).pipe(
|
||||||
Effect.andThen(identity),
|
Effect.andThen(identity),
|
||||||
Effect.andThen(value => this.submitValue(value)),
|
Effect.andThen(value => this.submitValue(value)),
|
||||||
)
|
)
|
||||||
@@ -153,7 +153,7 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
|
|||||||
e => e._tag === "ParseError",
|
e => e._tag === "ParseError",
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
onSome: e => Ref.set(this.error, Option.some(e)),
|
onSome: e => Lens.set(this.error, Option.some(e)),
|
||||||
onNone: () => Effect.void,
|
onNone: () => Effect.void,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -193,10 +193,10 @@ export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void,
|
|||||||
options.autosubmit ?? false,
|
options.autosubmit ?? false,
|
||||||
Option.fromNullable(options.debounce),
|
Option.fromNullable(options.debounce),
|
||||||
|
|
||||||
yield* SubscriptionRef.make(Option.none<A>()),
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<A>())),
|
||||||
yield* SubscriptionRef.make(options.initialEncodedValue),
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)),
|
||||||
yield* SubscriptionRef.make(Option.none<ParseResult.ParseError>()),
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<ParseResult.ParseError>())),
|
||||||
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>()),
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>())),
|
||||||
|
|
||||||
yield* Effect.makeSemaphore(1),
|
yield* Effect.makeSemaphore(1),
|
||||||
yield* Ref.make(HashMap.empty<FormFieldKey, FormField<unknown, unknown>>()),
|
yield* Ref.make(HashMap.empty<FormFieldKey, FormField<unknown, unknown>>()),
|
||||||
@@ -228,7 +228,7 @@ extends Pipeable.Pipeable {
|
|||||||
readonly [FormFieldTypeId]: FormFieldTypeId
|
readonly [FormFieldTypeId]: FormFieldTypeId
|
||||||
|
|
||||||
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>
|
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>
|
||||||
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
|
readonly encodedValue: Lens.Lens<I>
|
||||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
|
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
|
||||||
readonly isValidating: Subscribable.Subscribable<boolean>
|
readonly isValidating: Subscribable.Subscribable<boolean>
|
||||||
readonly isSubmitting: Subscribable.Subscribable<boolean>
|
readonly isSubmitting: Subscribable.Subscribable<boolean>
|
||||||
@@ -240,7 +240,7 @@ extends Pipeable.Class() implements FormField<A, I> {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>,
|
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>,
|
||||||
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
|
readonly encodedValue: Lens.Lens<I>,
|
||||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
|
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
|
||||||
readonly isValidating: Subscribable.Subscribable<boolean>,
|
readonly isValidating: Subscribable.Subscribable<boolean>,
|
||||||
readonly isSubmitting: Subscribable.Subscribable<boolean>,
|
readonly isSubmitting: Subscribable.Subscribable<boolean>,
|
||||||
@@ -276,7 +276,7 @@ export const makeFormField = <A, I, R, MA, ME, MR, MP, const P extends PropertyP
|
|||||||
onSome: v => Option.map(PropertyPath.get(v, path), Option.some),
|
onSome: v => Option.map(PropertyPath.get(v, path), Option.some),
|
||||||
onNone: () => Option.some(Option.none()),
|
onNone: () => Option.some(Option.none()),
|
||||||
})),
|
})),
|
||||||
SubscriptionSubRef.makeFromPath(self.encodedValue, path),
|
Lens.map(self.encodedValue, a => Option.getOrThrow(PropertyPath.get(a, path)), (a, b) => Option.getOrThrow(PropertyPath.immutableSet(a, path, b))),
|
||||||
Subscribable.mapEffect(self.error, Option.match({
|
Subscribable.mapEffect(self.error, Option.match({
|
||||||
onSome: flow(
|
onSome: flow(
|
||||||
ParseResult.ArrayFormatter.formatError,
|
ParseResult.ArrayFormatter.formatError,
|
||||||
@@ -305,29 +305,35 @@ export const useInput = Effect.fnUntraced(function* <A, I>(
|
|||||||
field: FormField<A, I>,
|
field: FormField<A, I>,
|
||||||
options?: useInput.Options,
|
options?: useInput.Options,
|
||||||
): Effect.fn.Return<useInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
|
): Effect.fn.Return<useInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
|
||||||
const internalValueRef = yield* Component.useOnChange(() => Effect.tap(
|
const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() {
|
||||||
Effect.andThen(field.encodedValue, SubscriptionRef.make),
|
const internalValueLens = yield* Lens.get(field.encodedValue).pipe(
|
||||||
internalValueRef => Effect.forkScoped(Effect.all([
|
Effect.flatMap(SubscriptionRef.make),
|
||||||
|
Effect.map(Lens.fromSubscriptionRef),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Effect.forkScoped(Effect.all([
|
||||||
Stream.runForEach(
|
Stream.runForEach(
|
||||||
Stream.drop(field.encodedValue, 1),
|
Stream.drop(field.encodedValue.changes, 1),
|
||||||
upstreamEncodedValue => Effect.whenEffect(
|
upstreamEncodedValue => Effect.whenEffect(
|
||||||
Ref.set(internalValueRef, upstreamEncodedValue),
|
Lens.set(internalValueLens, upstreamEncodedValue),
|
||||||
Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
|
Effect.andThen(Lens.get(internalValueLens), internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
Stream.runForEach(
|
Stream.runForEach(
|
||||||
internalValueRef.changes.pipe(
|
internalValueLens.changes.pipe(
|
||||||
Stream.drop(1),
|
Stream.drop(1),
|
||||||
Stream.changesWith(Equal.equivalence()),
|
Stream.changesWith(Equal.equivalence()),
|
||||||
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
||||||
),
|
),
|
||||||
internalValue => Ref.set(field.encodedValue, internalValue),
|
internalValue => Lens.set(field.encodedValue, internalValue),
|
||||||
),
|
),
|
||||||
], { concurrency: "unbounded" })),
|
], { concurrency: "unbounded" }))
|
||||||
), [field, options?.debounce])
|
|
||||||
|
|
||||||
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
|
return internalValueLens
|
||||||
|
}), [field, options?.debounce])
|
||||||
|
|
||||||
|
const [value, setValue] = yield* Lens.useState(internalValueLens)
|
||||||
return { value, setValue }
|
return { value, setValue }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -346,51 +352,59 @@ export const useOptionalInput = Effect.fnUntraced(function* <A, I>(
|
|||||||
field: FormField<A, Option.Option<I>>,
|
field: FormField<A, Option.Option<I>>,
|
||||||
options: useOptionalInput.Options<I>,
|
options: useOptionalInput.Options<I>,
|
||||||
): Effect.fn.Return<useOptionalInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
|
): Effect.fn.Return<useOptionalInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
|
||||||
const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap(
|
const [enabledLens, internalValueLens] = yield* Component.useOnChange(() => Effect.gen(function*() {
|
||||||
Effect.andThen(
|
const [enabledLens, internalValueLens] = yield* Effect.flatMap(
|
||||||
field.encodedValue,
|
Lens.get(field.encodedValue),
|
||||||
Option.match({
|
Option.match({
|
||||||
onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]),
|
onSome: v => Effect.all([
|
||||||
onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]),
|
Effect.map(SubscriptionRef.make(true), Lens.fromSubscriptionRef),
|
||||||
|
Effect.map(SubscriptionRef.make(v), Lens.fromSubscriptionRef),
|
||||||
|
]),
|
||||||
|
onNone: () => Effect.all([
|
||||||
|
Effect.map(SubscriptionRef.make(false), Lens.fromSubscriptionRef),
|
||||||
|
Effect.map(SubscriptionRef.make(options.defaultValue), Lens.fromSubscriptionRef),
|
||||||
|
]),
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
|
||||||
([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([
|
yield* Effect.forkScoped(Effect.all([
|
||||||
Stream.runForEach(
|
Stream.runForEach(
|
||||||
Stream.drop(field.encodedValue, 1),
|
Stream.drop(field.encodedValue.changes, 1),
|
||||||
|
|
||||||
upstreamEncodedValue => Effect.whenEffect(
|
upstreamEncodedValue => Effect.whenEffect(
|
||||||
Option.match(upstreamEncodedValue, {
|
Option.match(upstreamEncodedValue, {
|
||||||
onSome: v => Effect.andThen(
|
onSome: v => Effect.andThen(
|
||||||
Ref.set(enabledRef, true),
|
Lens.set(enabledLens, true),
|
||||||
Ref.set(internalValueRef, v),
|
Lens.set(internalValueLens, v),
|
||||||
),
|
),
|
||||||
onNone: () => Effect.andThen(
|
onNone: () => Effect.andThen(
|
||||||
Ref.set(enabledRef, false),
|
Lens.set(enabledLens, false),
|
||||||
Ref.set(internalValueRef, options.defaultValue),
|
Lens.set(internalValueLens, options.defaultValue),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
Effect.all([enabledRef, internalValueRef]),
|
Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]),
|
||||||
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
Stream.runForEach(
|
Stream.runForEach(
|
||||||
enabledRef.changes.pipe(
|
enabledLens.changes.pipe(
|
||||||
Stream.zipLatest(internalValueRef.changes),
|
Stream.zipLatest(internalValueLens.changes),
|
||||||
Stream.drop(1),
|
Stream.drop(1),
|
||||||
Stream.changesWith(Equal.equivalence()),
|
Stream.changesWith(Equal.equivalence()),
|
||||||
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
||||||
),
|
),
|
||||||
([enabled, internalValue]) => Ref.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
([enabled, internalValue]) => Lens.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
||||||
),
|
),
|
||||||
], { concurrency: "unbounded" })),
|
], { concurrency: "unbounded" }))
|
||||||
), [field, options.debounce])
|
|
||||||
|
|
||||||
const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef)
|
return [enabledLens, internalValueLens] as const
|
||||||
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
|
}), [field, options.debounce])
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = yield* Lens.useState(enabledLens)
|
||||||
|
const [value, setValue] = yield* Lens.useState(internalValueLens)
|
||||||
return { enabled, setEnabled, value, setValue }
|
return { enabled, setEnabled, value, setValue }
|
||||||
})
|
})
|
||||||
|
|||||||
63
packages/effect-fc/src/Lens.ts
Normal file
63
packages/effect-fc/src/Lens.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Effect, Equivalence, Stream } from "effect"
|
||||||
|
import { Lens } from "effect-lens"
|
||||||
|
import * as React from "react"
|
||||||
|
import * as Component from "./Component.js"
|
||||||
|
import * as SetStateAction from "./SetStateAction.js"
|
||||||
|
import * as SubscriptionRef from "./SubscriptionRef.js"
|
||||||
|
|
||||||
|
|
||||||
|
export declare namespace useState {
|
||||||
|
export interface Options<A> {
|
||||||
|
readonly equivalence?: Equivalence.Equivalence<A>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useState = Effect.fnUntraced(function* <A, ER, EW, RR, RW>(
|
||||||
|
lens: Lens.Lens<A, ER, EW, RR, RW>,
|
||||||
|
options?: useState.Options<NoInfer<A>>,
|
||||||
|
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>], ER, RR | RW> {
|
||||||
|
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => Lens.get(lens)))
|
||||||
|
|
||||||
|
yield* Component.useReactEffect(() => Effect.forkScoped(
|
||||||
|
Stream.runForEach(
|
||||||
|
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strict()),
|
||||||
|
v => Effect.sync(() => setReactStateValue(v)),
|
||||||
|
)
|
||||||
|
), [lens])
|
||||||
|
|
||||||
|
const setValue = yield* Component.useCallbackSync(
|
||||||
|
(setStateAction: React.SetStateAction<A>) => Effect.andThen(
|
||||||
|
Lens.updateAndGet(lens, prevState => SetStateAction.value(setStateAction, prevState)),
|
||||||
|
v => setReactStateValue(v),
|
||||||
|
),
|
||||||
|
[lens],
|
||||||
|
)
|
||||||
|
|
||||||
|
return [reactStateValue, setValue]
|
||||||
|
})
|
||||||
|
|
||||||
|
export declare namespace useFromState {
|
||||||
|
export interface Options<A> {
|
||||||
|
readonly equivalence?: Equivalence.Equivalence<A>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFromState = Effect.fnUntraced(function* <A>(
|
||||||
|
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
|
||||||
|
options?: useFromState.Options<NoInfer<A>>,
|
||||||
|
): Effect.fn.Return<Lens.Lens<A, never, never, never, never>> {
|
||||||
|
const lens = yield* Component.useOnMount(() => Effect.map(
|
||||||
|
SubscriptionRef.make(value),
|
||||||
|
Lens.fromSubscriptionRef,
|
||||||
|
))
|
||||||
|
|
||||||
|
yield* Component.useReactEffect(() => Effect.forkScoped(Stream.runForEach(
|
||||||
|
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strict()),
|
||||||
|
v => Effect.sync(() => setValue(v)),
|
||||||
|
)), [setValue])
|
||||||
|
yield* Component.useReactEffect(() => Lens.set(lens, value), [value])
|
||||||
|
|
||||||
|
return lens
|
||||||
|
})
|
||||||
|
|
||||||
|
export * from "effect-lens/Lens"
|
||||||
67
packages/effect-fc/src/PropertyPath.test.ts
Normal file
67
packages/effect-fc/src/PropertyPath.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Option } from "effect"
|
||||||
|
import * as PropertyPath from "./PropertyPath.js"
|
||||||
|
|
||||||
|
|
||||||
|
describe("immutableSet with arrays", () => {
|
||||||
|
test("sets a top-level array element", () => {
|
||||||
|
const arr = [1, 2, 3]
|
||||||
|
const result = PropertyPath.immutableSet(arr, [1], 99)
|
||||||
|
expect(result).toEqual(Option.some([1, 99, 3]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not mutate the original array", () => {
|
||||||
|
const arr = [1, 2, 3]
|
||||||
|
PropertyPath.immutableSet(arr, [0], 42)
|
||||||
|
expect(arr).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sets the first element of an array", () => {
|
||||||
|
const arr = ["a", "b", "c"]
|
||||||
|
const result = PropertyPath.immutableSet(arr, [0], "z")
|
||||||
|
expect(result).toEqual(Option.some(["z", "b", "c"]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sets the last element of an array", () => {
|
||||||
|
const arr = [10, 20, 30]
|
||||||
|
const result = PropertyPath.immutableSet(arr, [2], 99)
|
||||||
|
expect(result).toEqual(Option.some([10, 20, 99]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sets a nested array element inside an object", () => {
|
||||||
|
const obj = { tags: ["foo", "bar", "baz"] }
|
||||||
|
const result = PropertyPath.immutableSet(obj, ["tags", 1], "qux")
|
||||||
|
expect(result).toEqual(Option.some({ tags: ["foo", "qux", "baz"] }))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sets a deeply nested value inside an array of objects", () => {
|
||||||
|
const obj = { items: [{ name: "alice" }, { name: "bob" }] }
|
||||||
|
const result = PropertyPath.immutableSet(obj, ["items", 0, "name"], "charlie")
|
||||||
|
expect(result).toEqual(Option.some({ items: [{ name: "charlie" }, { name: "bob" }] }))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sets a value in a nested array", () => {
|
||||||
|
const matrix = [[1, 2], [3, 4]]
|
||||||
|
const result = PropertyPath.immutableSet(matrix, [1, 0], 99)
|
||||||
|
expect(result).toEqual(Option.some([[1, 2], [99, 4]]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns Option.none() for an out-of-bounds index", () => {
|
||||||
|
const arr = [1, 2, 3]
|
||||||
|
const result = PropertyPath.immutableSet(arr, [5], 99)
|
||||||
|
expect(result).toEqual(Option.none())
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns Option.none() for a non-numeric key on an array", () => {
|
||||||
|
const arr = [1, 2, 3]
|
||||||
|
// @ts-expect-error intentionally wrong key type
|
||||||
|
const result = PropertyPath.immutableSet(arr, ["length"], 0)
|
||||||
|
expect(result).toEqual(Option.none())
|
||||||
|
})
|
||||||
|
|
||||||
|
test("empty path returns Option.some of the value itself", () => {
|
||||||
|
const arr = [1, 2, 3]
|
||||||
|
const result = PropertyPath.immutableSet(arr, [], [9, 9, 9] as any)
|
||||||
|
expect(result).toEqual(Option.some([9, 9, 9]))
|
||||||
|
})
|
||||||
|
})
|
||||||
183
packages/effect-fc/src/SubscriptionSubRef.test.ts
Normal file
183
packages/effect-fc/src/SubscriptionSubRef.test.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Chunk, Effect, Ref, SubscriptionRef } from "effect"
|
||||||
|
import * as SubscriptionSubRef from "./SubscriptionSubRef.js"
|
||||||
|
|
||||||
|
|
||||||
|
describe("SubscriptionSubRef with array refs", () => {
|
||||||
|
test("creates a subref for a single array element using path", async () => {
|
||||||
|
const value = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make([{ name: "alice" }, { name: "bob" }, { name: "charlie" }]),
|
||||||
|
parent => {
|
||||||
|
const subref = SubscriptionSubRef.makeFromPath(parent, [1, "name"])
|
||||||
|
return subref.get
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(value).toBe("bob")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("modifies a single array element via subref", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make([{ name: "alice" }, { name: "bob" }, { name: "charlie" }]),
|
||||||
|
parent => {
|
||||||
|
const subref = SubscriptionSubRef.makeFromPath(parent, [1, "name"])
|
||||||
|
return Effect.flatMap(
|
||||||
|
Ref.set(subref, "bob-updated"),
|
||||||
|
() => Ref.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual([{ name: "alice" }, { name: "bob-updated" }, { name: "charlie" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("modifies array element at index 0", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make([10, 20, 30]),
|
||||||
|
parent => {
|
||||||
|
const subref = SubscriptionSubRef.makeFromPath(parent, [0])
|
||||||
|
return Effect.flatMap(
|
||||||
|
Ref.set(subref, 99),
|
||||||
|
() => Ref.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual([99, 20, 30])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("modifies array element at last index", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make(["a", "b", "c"]),
|
||||||
|
parent => {
|
||||||
|
const subref = SubscriptionSubRef.makeFromPath(parent, [2])
|
||||||
|
return Effect.flatMap(
|
||||||
|
Ref.set(subref, "z"),
|
||||||
|
() => Ref.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual(["a", "b", "z"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("modifies nested array element", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make([[1, 2], [3, 4], [5, 6]]),
|
||||||
|
parent => {
|
||||||
|
const subref = SubscriptionSubRef.makeFromPath(parent, [1, 0])
|
||||||
|
return Effect.flatMap(
|
||||||
|
Ref.set(subref, 99),
|
||||||
|
() => Ref.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual([[1, 2], [99, 4], [5, 6]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses modifyEffect to transform array element", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make([{ count: 1 }, { count: 2 }, { count: 3 }]),
|
||||||
|
parent => {
|
||||||
|
const subref = SubscriptionSubRef.makeFromPath(parent, [1, "count"])
|
||||||
|
return Effect.flatMap(
|
||||||
|
Ref.update(subref, count => count + 100),
|
||||||
|
() => Effect.map(Ref.get(parent), parentValue => ({ result: 102, parentValue })),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.result).toBe(102) // count + 100
|
||||||
|
expect(result.parentValue).toEqual([{ count: 1 }, { count: 102 }, { count: 3 }]) // count + 100
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses modify to transform array element", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make([10, 20, 30]),
|
||||||
|
parent => {
|
||||||
|
const subref = SubscriptionSubRef.makeFromPath(parent, [1])
|
||||||
|
return Effect.flatMap(
|
||||||
|
Ref.update(subref, x => x + 5),
|
||||||
|
() => Effect.map(Ref.get(parent), parentValue => ({ result: 25, parentValue })),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.result).toBe(25) // 20 + 5
|
||||||
|
expect(result.parentValue).toEqual([10, 25, 30]) // 20 + 5
|
||||||
|
})
|
||||||
|
|
||||||
|
test("makeFromChunkIndex modifies chunk element", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make(Chunk.make(100, 200, 300)),
|
||||||
|
parent => {
|
||||||
|
const subref = SubscriptionSubRef.makeFromChunkIndex(parent, 1)
|
||||||
|
return Effect.flatMap(
|
||||||
|
Ref.set(subref, 999),
|
||||||
|
() => Ref.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(Chunk.toReadonlyArray(result)).toEqual([100, 999, 300])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("makeFromGetSet with custom getter/setter for array element", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make([{ id: 1, value: "a" }, { id: 2, value: "b" }]),
|
||||||
|
parent => {
|
||||||
|
const subref = SubscriptionSubRef.makeFromGetSet(parent, {
|
||||||
|
get: arr => arr[0].value,
|
||||||
|
set: (arr, newValue) => [
|
||||||
|
{ ...arr[0], value: newValue },
|
||||||
|
...arr.slice(1),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return Effect.flatMap(
|
||||||
|
Ref.set(subref, "updated"),
|
||||||
|
() => Ref.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual([{ id: 1, value: "updated" }, { id: 2, value: "b" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not mutate original array when modifying via subref", async () => {
|
||||||
|
const original = [{ name: "alice" }, { name: "bob" }]
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make(original),
|
||||||
|
parent => {
|
||||||
|
const subref = SubscriptionSubRef.makeFromPath(parent, [0, "name"])
|
||||||
|
return Effect.flatMap(
|
||||||
|
Ref.set(subref, "alice-updated"),
|
||||||
|
() => Ref.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(original).toEqual([{ name: "alice" }, { name: "bob" }]) // original unchanged
|
||||||
|
expect(result).toEqual([{ name: "alice-updated" }, { name: "bob" }]) // new value in ref
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ export * as Async from "./Async.js"
|
|||||||
export * as Component from "./Component.js"
|
export * as Component from "./Component.js"
|
||||||
export * as ErrorObserver from "./ErrorObserver.js"
|
export * as ErrorObserver from "./ErrorObserver.js"
|
||||||
export * as Form from "./Form.js"
|
export * as Form from "./Form.js"
|
||||||
|
export * as Lens from "./Lens.js"
|
||||||
export * as Memoized from "./Memoized.js"
|
export * as Memoized from "./Memoized.js"
|
||||||
export * as Mutation from "./Mutation.js"
|
export * as Mutation from "./Mutation.js"
|
||||||
export * as PropertyPath from "./PropertyPath.js"
|
export * as PropertyPath from "./PropertyPath.js"
|
||||||
|
|||||||
@@ -34,5 +34,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
"include": ["./src"]
|
"include": ["./src"],
|
||||||
|
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,30 +13,30 @@
|
|||||||
"clean:modules": "rm -rf node_modules"
|
"clean:modules": "rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/react-router": "^1.154.12",
|
"@tanstack/react-router": "^1.168.3",
|
||||||
"@tanstack/react-router-devtools": "^1.154.12",
|
"@tanstack/react-router-devtools": "^1.166.11",
|
||||||
"@tanstack/router-plugin": "^1.154.12",
|
"@tanstack/router-plugin": "^1.167.4",
|
||||||
"@types/react": "^19.2.9",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.4.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.4",
|
||||||
"type-fest": "^5.4.1",
|
"type-fest": "^5.5.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^8.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform": "^0.94.2",
|
"@effect/platform": "^0.96.0",
|
||||||
"@effect/platform-browser": "^0.74.0",
|
"@effect/platform-browser": "^0.76.0",
|
||||||
"@radix-ui/themes": "^3.2.1",
|
"@radix-ui/themes": "^3.3.0",
|
||||||
"@typed/id": "^0.17.2",
|
"@typed/id": "^0.17.2",
|
||||||
"effect": "^3.19.15",
|
"effect": "^3.21.0",
|
||||||
"effect-fc": "workspace:*",
|
"effect-fc": "workspace:*",
|
||||||
"react-icons": "^5.5.0"
|
"react-icons": "^5.6.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "^19.2.9",
|
"@types/react": "^19.2.14",
|
||||||
"effect": "^3.19.15",
|
"effect": "^3.21.0",
|
||||||
"react": "^19.2.3"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user