23 Commits

Author SHA1 Message Date
11c4a9bf28 Update dependency vite to v8
Some checks failed
Lint / lint (push) Failing after 6s
Test build / test-build (pull_request) Failing after 7s
2026-03-13 12:01:19 +00:00
Julien Valverdé
2457b1c536 Fix
All checks were successful
Lint / lint (push) Successful in 11s
2026-03-11 21:42:19 +01:00
Julien Valverdé
3cb3f6d103 Update docs
All checks were successful
Lint / lint (push) Successful in 11s
2026-03-11 21:20:20 +01:00
Julien Valverdé
46d7aacc69 Add comments
All checks were successful
Lint / lint (push) Successful in 16s
2026-03-10 20:51:04 +01:00
Julien Valverdé
2f118c5f98 Fix
All checks were successful
Lint / lint (push) Successful in 11s
2026-03-10 20:49:13 +01:00
Julien Valverdé
0ba00a0b4f Make nonReactiveTags a Component option
All checks were successful
Lint / lint (push) Successful in 12s
2026-03-10 20:48:31 +01:00
Julien Valverdé
c644f8c44b Fix Async docs
All checks were successful
Lint / lint (push) Successful in 12s
2026-03-10 19:46:44 +01:00
Julien Valverdé
6917c72101 Add comments
All checks were successful
Lint / lint (push) Successful in 12s
2026-03-10 03:18:45 +01:00
Julien Valverdé
9b3ce62d3e Fix
All checks were successful
Lint / lint (push) Successful in 12s
2026-03-10 02:43:33 +01:00
Julien Valverdé
8b69d4e500 Fix
All checks were successful
Lint / lint (push) Successful in 12s
2026-03-10 02:35:43 +01:00
Julien Valverdé
a9c0590b7c Fix Async example
All checks were successful
Lint / lint (push) Successful in 41s
2026-03-10 02:22:17 +01:00
Julien Valverdé
b63d1ab2c7 Fix
All checks were successful
Lint / lint (push) Successful in 32s
2026-03-09 20:21:18 +01:00
Julien Valverdé
df86af839e Fix Async
All checks were successful
Lint / lint (push) Successful in 12s
2026-03-07 19:45:23 +01:00
Julien Valverdé
dbe42aadb1 Fix
All checks were successful
Lint / lint (push) Successful in 42s
2026-03-05 11:39:51 +01:00
Julien Valverdé
355e179fbd Fix
All checks were successful
Lint / lint (push) Successful in 11s
2026-03-03 16:48:27 +01:00
Julien Valverdé
8dd40d3365 Fix
All checks were successful
Lint / lint (push) Successful in 12s
2026-03-03 15:39:38 +01:00
Julien Valverdé
929f835e94 Restore Async
All checks were successful
Lint / lint (push) Successful in 11s
2026-03-03 15:35:23 +01:00
Julien Valverdé
1f47887643 Fix
All checks were successful
Lint / lint (push) Successful in 11s
2026-03-03 13:47:48 +01:00
Julien Valverdé
3794f56a86 Async example
All checks were successful
Lint / lint (push) Successful in 11s
2026-03-03 13:37:27 +01:00
Julien Valverdé
7f8f91bfc5 Fix
All checks were successful
Lint / lint (push) Successful in 12s
2026-03-03 13:20:19 +01:00
Julien Valverdé
45b38d6c1f Refactor Async
All checks were successful
Lint / lint (push) Successful in 12s
2026-03-03 13:17:47 +01:00
Julien Valverdé
2080d35b2c Fix
All checks were successful
Lint / lint (push) Successful in 40s
2026-03-02 04:02:21 +01:00
Julien Valverdé
346ba9066b Fix
All checks were successful
Lint / lint (push) Successful in 44s
2026-03-01 14:34:50 +01:00
10 changed files with 282 additions and 143 deletions

View File

@@ -1,31 +1,47 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ /** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Effect, Function, Predicate, Runtime, Scope } from "effect" import { Effect, type Equivalence, Function, Predicate, Runtime, Scope } from "effect"
import * as React from "react" import * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Async/Async") export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
export type TypeId = typeof TypeId export type AsyncTypeId = typeof AsyncTypeId
export interface Async extends AsyncOptions {
readonly [TypeId]: TypeId /**
* A trait for `Component`'s that allows them running asynchronous effects.
*/
export interface Async extends AsyncPrototype, AsyncOptions {}
export interface AsyncPrototype {
readonly [AsyncTypeId]: AsyncTypeId
} }
/**
* Configuration options for `Async` components.
*/
export interface AsyncOptions { export interface AsyncOptions {
/**
* The default fallback React node to display while the async operation is pending.
* Used if no fallback is provided to the component when rendering.
*/
readonly defaultFallback?: React.ReactNode readonly defaultFallback?: React.ReactNode
} }
/**
* Props for `Async` components.
*/
export type AsyncProps = Omit<React.SuspenseProps, "children"> export type AsyncProps = Omit<React.SuspenseProps, "children">
export const AsyncPrototype = Object.freeze({ export const AsyncPrototype: AsyncPrototype = Object.freeze({
[TypeId]: TypeId, [AsyncTypeId]: AsyncTypeId,
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R>( asFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
this: Component.Component<P, A, E, R> & Async, this: Component.Component<P, A, E, R> & Async,
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>, runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
) { ) {
const SuspenseInner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise) const Inner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
return ({ fallback, name, ...props }: AsyncProps) => { return ({ fallback, name, ...props }: AsyncProps) => {
const promise = Runtime.runPromise(runtimeRef.current)( const promise = Runtime.runPromise(runtimeRef.current)(
@@ -38,17 +54,64 @@ export const AsyncPrototype = Object.freeze({
return React.createElement( return React.createElement(
React.Suspense, React.Suspense,
{ fallback: fallback ?? this.defaultFallback, name }, { fallback: fallback ?? this.defaultFallback, name },
React.createElement(SuspenseInner, { promise }), React.createElement(Inner, { promise }),
) )
} }
}, },
} as const) } as const)
/**
* An equivalence function for comparing `AsyncProps` that ignores the `fallback` property.
* Used by default by async components with `Memoized.memoized` applied.
*/
export const defaultPropsEquivalence: Equivalence.Equivalence<AsyncProps> = (
self: Record<string, unknown>,
that: Record<string, unknown>,
) => {
if (self === that)
return true
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, TypeId) for (const key in self) {
if (key === "fallback")
continue
if (!(key in that) || !Object.is(self[key], that[key]))
return false
}
for (const key in that) {
if (key === "fallback")
continue
if (!(key in self))
return false
}
return true
}
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, AsyncTypeId)
/**
* Converts a Component into an `Async` component that supports running asynchronous effects.
*
* Note: The component cannot have a prop named "promise" as it's reserved for internal use.
*
* @param self - The component to convert to an Async component
* @returns A new `Async` component with the same body, error, and context types as the input
*
* @example
* ```ts
* const MyAsyncComponent = MyComponent.pipe(
* Async.async,
* )
* ```
*/
export const async = <T extends Component.Component<any, any, any, any>>( export const async = <T extends Component.Component<any, any, any, any>>(
self: T self: T & (
"promise" extends keyof Component.Component.Props<T>
? "The 'promise' prop name is restricted for Async components. Please rename the 'promise' prop to something else."
: T
)
): ( ): (
& Omit<T, keyof Component.Component.AsComponent<T>> & Omit<T, keyof Component.Component.AsComponent<T>>
& Component.Component< & Component.Component<
@@ -59,13 +122,37 @@ export const async = <T extends Component.Component<any, any, any, any>>(
> >
& Async & Async
) => Object.setPrototypeOf( ) => Object.setPrototypeOf(
Object.assign(function() {}, self), Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }),
Object.freeze(Object.setPrototypeOf( Object.freeze(Object.setPrototypeOf(
Object.assign({}, AsyncPrototype), Object.assign({}, AsyncPrototype),
Object.getPrototypeOf(self), Object.getPrototypeOf(self),
)), )),
) )
/**
* Applies options to an Async component, returning a new Async component with the updated configuration.
*
* Supports both curried and uncurried application styles.
*
* @param self - The Async component to apply options to (in uncurried form)
* @param options - The options to apply to the component
* @returns An Async component with the applied options
*
* @example
* ```ts
* // Curried
* const MyAsyncComponent = MyComponent.pipe(
* Async.async,
* Async.withOptions({ defaultFallback: <p>Loading...</p> }),
* )
*
* // Uncurried
* const MyAsyncComponent = Async.withOptions(
* Async.async(MyComponent),
* { defaultFallback: <p>Loading...</p> },
* )
* ```
*/
export const withOptions: { export const withOptions: {
<T extends Component.Component<any, any, any, any> & Async>( <T extends Component.Component<any, any, any, any> & Async>(
options: Partial<AsyncOptions> options: Partial<AsyncOptions>

View File

@@ -4,8 +4,8 @@ import { Context, type Duration, Effect, Equivalence, ExecutionStrategy, Exit, F
import * as React from "react" import * as React from "react"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Component/Component") export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component")
export type TypeId = typeof TypeId export type ComponentTypeId = typeof ComponentTypeId
/** /**
* Represents an Effect-based React Component that integrates the Effect system with React. * Represents an Effect-based React Component that integrates the Effect system with React.
@@ -13,7 +13,7 @@ export type TypeId = typeof TypeId
export interface Component<P extends {}, A extends React.ReactNode, E, R> export interface Component<P extends {}, A extends React.ReactNode, E, R>
extends ComponentPrototype<P, A, R>, ComponentOptions { extends ComponentPrototype<P, A, R>, ComponentOptions {
new(_: never): Record<string, never> new(_: never): Record<string, never>
readonly [TypeId]: TypeId readonly [ComponentTypeId]: ComponentTypeId
readonly "~Props": P readonly "~Props": P
readonly "~Success": A readonly "~Success": A
readonly "~Error": E readonly "~Error": E
@@ -34,7 +34,7 @@ export declare namespace Component {
export interface ComponentPrototype<P extends {}, A extends React.ReactNode, R> export interface ComponentPrototype<P extends {}, A extends React.ReactNode, R>
extends Pipeable.Pipeable { extends Pipeable.Pipeable {
readonly [TypeId]: TypeId readonly [ComponentTypeId]: ComponentTypeId
readonly use: Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>> readonly use: Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>>
asFunctionComponent( asFunctionComponent(
@@ -46,7 +46,7 @@ extends Pipeable.Pipeable {
} }
export const ComponentPrototype: ComponentPrototype<any, any, any> = Object.freeze({ export const ComponentPrototype: ComponentPrototype<any, any, any> = Object.freeze({
[TypeId]: TypeId, [ComponentTypeId]: ComponentTypeId,
...Pipeable.Prototype, ...Pipeable.Prototype,
get use() { return use(this) }, get use() { return use(this) },
@@ -88,7 +88,7 @@ const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode
}), }),
Equivalence.array(Equivalence.strict()), Equivalence.array(Equivalence.strict()),
)))[0](Array.from( )))[0](Array.from(
Context.omit(...nonReactiveTags)(runtimeRef.current.context).unsafeMap.values() Context.omit(...self.nonReactiveTags)(runtimeRef.current.context).unsafeMap.values()
)) ))
}) })
@@ -96,10 +96,16 @@ const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode
export interface ComponentOptions { export interface ComponentOptions {
/** /**
* Custom display name for the component in React DevTools and debugging utilities. * Custom display name for the component in React DevTools and debugging utilities.
* Improves developer experience by providing meaningful component identification.
*/ */
readonly displayName?: string readonly displayName?: string
/**
* Context tags that should not trigger component remount when their values change.
*
* @default [Tracer.ParentSpan]
*/
readonly nonReactiveTags: readonly Context.Tag<any, any>[]
/** /**
* Specifies the execution strategy for finalizers when the component unmounts or its scope closes. * Specifies the execution strategy for finalizers when the component unmounts or its scope closes.
* Determines whether finalizers execute sequentially or in parallel. * Determines whether finalizers execute sequentially or in parallel.
@@ -119,15 +125,13 @@ export interface ComponentOptions {
} }
export const defaultOptions: ComponentOptions = { export const defaultOptions: ComponentOptions = {
nonReactiveTags: [Tracer.ParentSpan],
finalizerExecutionStrategy: ExecutionStrategy.sequential, finalizerExecutionStrategy: ExecutionStrategy.sequential,
finalizerExecutionDebounce: "100 millis", finalizerExecutionDebounce: "100 millis",
} }
export const nonReactiveTags = [Tracer.ParentSpan] as const export const isComponent = (u: unknown): u is Component<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, ComponentTypeId)
export const isComponent = (u: unknown): u is Component<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, TypeId)
export declare namespace make { export declare namespace make {
export type Gen = { export type Gen = {
@@ -507,7 +511,7 @@ export const makeUntraced: (
* const MyComponentWithCustomOptions = MyComponent.pipe( * const MyComponentWithCustomOptions = MyComponent.pipe(
* Component.withOptions({ * Component.withOptions({
* finalizerExecutionStrategy: ExecutionStrategy.parallel, * finalizerExecutionStrategy: ExecutionStrategy.parallel,
* finalizerExecutionDebounce: "50 millis" * finalizerExecutionDebounce: "50 millis",
* }) * })
* ) * )
* ``` * ```

View File

@@ -1,20 +1,20 @@
import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect" import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect"
export const TypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver") export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
export type TypeId = typeof TypeId export type ErrorObserverTypeId = typeof ErrorObserverTypeId
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable { export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
readonly [TypeId]: TypeId readonly [ErrorObserverTypeId]: ErrorObserverTypeId
handle<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> handle<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope> readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
} }
export const ErrorObserver = <E = never>(): Context.Tag<ErrorObserver, ErrorObserver<E>> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver") export const ErrorObserver = <E = never>(): Context.Tag<ErrorObserver, ErrorObserver<E>> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver")
class ErrorObserverImpl<in out E = never> export class ErrorObserverImpl<in out E = never>
extends Pipeable.Class() implements ErrorObserver<E> { extends Pipeable.Class() implements ErrorObserver<E> {
readonly [TypeId]: TypeId = TypeId readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope> readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
constructor( constructor(
@@ -29,7 +29,7 @@ extends Pipeable.Class() implements ErrorObserver<E> {
} }
} }
class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> { export class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
readonly value = Effect.void readonly value = Effect.void
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) { constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) {
super() super()
@@ -43,7 +43,7 @@ class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
} }
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, TypeId) export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, ErrorObserverTypeId)
export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map( export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map(
PubSub.unbounded<Cause.Cause<never>>(), PubSub.unbounded<Cause.Cause<never>>(),

View File

@@ -4,20 +4,38 @@ import * as React from "react"
import type * as Component from "./Component.js" import type * as Component from "./Component.js"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized") export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
export type TypeId = typeof TypeId export type MemoizedTypeId = typeof MemoizedTypeId
export interface Memoized<P> extends MemoizedOptions<P> {
readonly [TypeId]: TypeId /**
* A trait for `Component`'s that uses `React.memo` to optimize re-renders based on prop equality.
*
* @template P The props type of the component
*/
export interface Memoized<P> extends MemoizedPrototype, MemoizedOptions<P> {}
export interface MemoizedPrototype {
readonly [MemoizedTypeId]: MemoizedTypeId
} }
/**
* Configuration options for Memoized components.
*
* @template P The props type of the component
*/
export interface MemoizedOptions<P> { export interface MemoizedOptions<P> {
/**
* An optional equivalence function for comparing component props.
* If provided, this function is used by React.memo to determine if props have changed.
* Returns `true` if props are equivalent (no re-render), `false` if they differ (re-render).
*/
readonly propsEquivalence?: Equivalence.Equivalence<P> readonly propsEquivalence?: Equivalence.Equivalence<P>
} }
export const MemoizedPrototype = Object.freeze({ export const MemoizedPrototype: MemoizedPrototype = Object.freeze({
[TypeId]: TypeId, [MemoizedTypeId]: MemoizedTypeId,
transformFunctionComponent<P extends {}>( transformFunctionComponent<P extends {}>(
this: Memoized<P>, this: Memoized<P>,
@@ -28,8 +46,21 @@ export const MemoizedPrototype = Object.freeze({
} as const) } as const)
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, TypeId) export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, MemoizedTypeId)
/**
* Converts a Component into a `Memoized` component that optimizes re-renders using `React.memo`.
*
* @param self - The component to convert to a Memoized component
* @returns A new `Memoized` component with the same body, error, and context types as the input
*
* @example
* ```ts
* const MyMemoizedComponent = MyComponent.pipe(
* Memoized.memoized,
* )
* ```
*/
export const memoized = <T extends Component.Component<any, any, any, any>>( export const memoized = <T extends Component.Component<any, any, any, any>>(
self: T self: T
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf( ): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
@@ -40,6 +71,30 @@ export const memoized = <T extends Component.Component<any, any, any, any>>(
)), )),
) )
/**
* Applies options to a Memoized component, returning a new Memoized component with the updated configuration.
*
* Supports both curried and uncurried application styles.
*
* @param self - The Memoized component to apply options to (in uncurried form)
* @param options - The options to apply to the component
* @returns A Memoized component with the applied options
*
* @example
* ```ts
* // Curried
* const MyMemoizedComponent = MyComponent.pipe(
* Memoized.memoized,
* Memoized.withOptions({ propsEquivalence: (a, b) => a.id === b.id }),
* )
*
* // Uncurried
* const MyMemoizedComponent = Memoized.withOptions(
* Memoized.memoized(MyComponent),
* { propsEquivalence: (a, b) => a.id === b.id },
* )
* ```
*/
export const withOptions: { export const withOptions: {
<T extends Component.Component<any, any, any, any> & Memoized<any>>( <T extends Component.Component<any, any, any, any> & Memoized<any>>(
options: Partial<MemoizedOptions<Component.Component.Props<T>>> options: Partial<MemoizedOptions<Component.Component.Props<T>>>

View File

@@ -10,7 +10,7 @@ export interface Query<in out K extends Query.AnyKey, in out A, in out KE = neve
extends Pipeable.Pipeable { extends Pipeable.Pipeable {
readonly [QueryTypeId]: QueryTypeId readonly [QueryTypeId]: QueryTypeId
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R> readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>
readonly key: Stream.Stream<K, KE, KR> readonly key: Stream.Stream<K, KE, KR>
readonly f: (key: K) => Effect.Effect<A, E, R> readonly f: (key: K) => Effect.Effect<A, E, R>
readonly initialProgress: P readonly initialProgress: P
@@ -287,7 +287,7 @@ export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE =
> { > {
const client = yield* QueryClient.QueryClient const client = yield* QueryClient.QueryClient
return new QueryImpl( return new QueryImpl<K, A, KE, KR, E, Result.forkEffect.OutputContext<A, E, R, P>, P>(
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<A, E, R, P>>(), yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<A, E, R, P>>(),
options.key, options.key,
options.f as any, options.f as any,

View File

@@ -240,16 +240,16 @@ export const unsafeForkEffect = <A, E, R, P = never>(
Effect.provide(Layer.empty.pipe( Effect.provide(Layer.empty.pipe(
Layer.provideMerge(makeProgressLayer<A, E, P>()), Layer.provideMerge(makeProgressLayer<A, E, P>()),
Layer.provideMerge(Layer.succeed(State<A, E, P>(), { Layer.provideMerge(Layer.succeed(State<A, E, P>(), {
get: ref, get: Ref.get(ref),
set: v => Effect.andThen(Ref.set(ref, v), PubSub.publish(pubsub, v)) set: v => Effect.andThen(Ref.set(ref, v), PubSub.publish(pubsub, v))
})), })),
)), )),
))), ))),
Effect.map(({ ref, pubsub, fiber }) => [ Effect.map(({ ref, pubsub, fiber }) => [
Subscribable.make({ Subscribable.make({
get: ref, get: Ref.get(ref),
changes: Stream.unwrapScoped(Effect.map( changes: Stream.unwrapScoped(Effect.map(
Effect.all([ref, Stream.fromPubSub(pubsub, { scoped: true })]), Effect.all([Ref.get(ref), Stream.fromPubSub(pubsub, { scoped: true })]),
([latest, stream]) => Stream.concat(Stream.make(latest), stream), ([latest, stream]) => Stream.concat(Stream.make(latest), stream),
)), )),
}), }),

View File

@@ -23,7 +23,7 @@
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"type-fest": "^5.4.1", "type-fest": "^5.4.1",
"vite": "^7.3.1" "vite": "^8.0.0"
}, },
"dependencies": { "dependencies": {
"@effect/platform": "^0.94.2", "@effect/platform": "^0.94.2",

View File

@@ -13,10 +13,10 @@ import { Route as ResultRouteImport } from './routes/result'
import { Route as QueryRouteImport } from './routes/query' import { Route as QueryRouteImport } from './routes/query'
import { Route as FormRouteImport } from './routes/form' import { Route as FormRouteImport } from './routes/form'
import { Route as BlankRouteImport } from './routes/blank' import { Route as BlankRouteImport } from './routes/blank'
import { Route as AsyncRouteImport } from './routes/async'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as DevMemoRouteImport } from './routes/dev/memo' import { Route as DevMemoRouteImport } from './routes/dev/memo'
import { Route as DevContextRouteImport } from './routes/dev/context' import { Route as DevContextRouteImport } from './routes/dev/context'
import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering'
const ResultRoute = ResultRouteImport.update({ const ResultRoute = ResultRouteImport.update({
id: '/result', id: '/result',
@@ -38,6 +38,11 @@ const BlankRoute = BlankRouteImport.update({
path: '/blank', path: '/blank',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AsyncRoute = AsyncRouteImport.update({
id: '/async',
path: '/async',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@@ -53,40 +58,35 @@ const DevContextRoute = DevContextRouteImport.update({
path: '/dev/context', path: '/dev/context',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({
id: '/dev/async-rendering',
path: '/dev/async-rendering',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/context': typeof DevContextRoute '/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute '/dev/memo': typeof DevMemoRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/context': typeof DevContextRoute '/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute '/dev/memo': typeof DevMemoRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/context': typeof DevContextRoute '/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute '/dev/memo': typeof DevMemoRoute
} }
@@ -94,42 +94,42 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/' | '/'
| '/async'
| '/blank' | '/blank'
| '/form' | '/form'
| '/query' | '/query'
| '/result' | '/result'
| '/dev/async-rendering'
| '/dev/context' | '/dev/context'
| '/dev/memo' | '/dev/memo'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/async'
| '/blank' | '/blank'
| '/form' | '/form'
| '/query' | '/query'
| '/result' | '/result'
| '/dev/async-rendering'
| '/dev/context' | '/dev/context'
| '/dev/memo' | '/dev/memo'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/async'
| '/blank' | '/blank'
| '/form' | '/form'
| '/query' | '/query'
| '/result' | '/result'
| '/dev/async-rendering'
| '/dev/context' | '/dev/context'
| '/dev/memo' | '/dev/memo'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AsyncRoute: typeof AsyncRoute
BlankRoute: typeof BlankRoute BlankRoute: typeof BlankRoute
FormRoute: typeof FormRoute FormRoute: typeof FormRoute
QueryRoute: typeof QueryRoute QueryRoute: typeof QueryRoute
ResultRoute: typeof ResultRoute ResultRoute: typeof ResultRoute
DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute
DevContextRoute: typeof DevContextRoute DevContextRoute: typeof DevContextRoute
DevMemoRoute: typeof DevMemoRoute DevMemoRoute: typeof DevMemoRoute
} }
@@ -164,6 +164,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof BlankRouteImport preLoaderRoute: typeof BlankRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/async': {
id: '/async'
path: '/async'
fullPath: '/async'
preLoaderRoute: typeof AsyncRouteImport
parentRoute: typeof rootRouteImport
}
'/': { '/': {
id: '/' id: '/'
path: '/' path: '/'
@@ -185,23 +192,16 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DevContextRouteImport preLoaderRoute: typeof DevContextRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/dev/async-rendering': {
id: '/dev/async-rendering'
path: '/dev/async-rendering'
fullPath: '/dev/async-rendering'
preLoaderRoute: typeof DevAsyncRenderingRouteImport
parentRoute: typeof rootRouteImport
}
} }
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AsyncRoute: AsyncRoute,
BlankRoute: BlankRoute, BlankRoute: BlankRoute,
FormRoute: FormRoute, FormRoute: FormRoute,
QueryRoute: QueryRoute, QueryRoute: QueryRoute,
ResultRoute: ResultRoute, ResultRoute: ResultRoute,
DevAsyncRenderingRoute: DevAsyncRenderingRoute,
DevContextRoute: DevContextRoute, DevContextRoute: DevContextRoute,
DevMemoRoute: DevMemoRoute, DevMemoRoute: DevMemoRoute,
} }

View File

@@ -0,0 +1,71 @@
import { HttpClient } from "@effect/platform"
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Array, Effect, flow, Option, Schema } from "effect"
import { Async, Component, Memoized } from "effect-fc"
import * as React from "react"
import { runtime } from "@/runtime"
const Post = Schema.Struct({
userId: Schema.Int,
id: Schema.Int,
title: Schema.String,
body: Schema.String,
})
interface AsyncFetchPostViewProps {
readonly id: number
}
class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) {
const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)),
), [props.id])
return (
<div>
<Heading>{post.title}</Heading>
<Text>{post.body}</Text>
</div>
)
}).pipe(
Async.async,
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }),
Memoized.memoized,
) {}
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
const [id, setId] = React.useState(1)
const AsyncFetchPost = yield* AsyncFetchPostView.use
return (
<Container>
<Flex direction="column" align="stretch" gap="2">
<TextField.Root
value={text}
onChange={e => setText(e.currentTarget.value)}
/>
<Slider
value={[id]}
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
/>
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
</Flex>
</Container>
)
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/async")({
component: AsyncRouteComponent,
})

View File

@@ -1,78 +0,0 @@
import { Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Effect } from "effect"
import { Async, Component, Memoized } from "effect-fc"
import * as React from "react"
import { runtime } from "@/runtime"
// Generator version
const RouteComponent = Component.makeUntraced(function* AsyncRendering() {
const MemoizedAsyncComponentFC = yield* MemoizedAsyncComponent.use
const AsyncComponentFC = yield* AsyncComponent.use
const [input, setInput] = React.useState("")
return (
<Flex direction="column" align="stretch" gap="2">
<TextField.Root
value={input}
onChange={e => setInput(e.target.value)}
/>
<MemoizedAsyncComponentFC fallback={React.useMemo(() => <p>Loading memoized...</p>, [])} />
<AsyncComponentFC />
</Flex>
)
}).pipe(
Component.withRuntime(runtime.context)
)
// Pipeline version
// const RouteComponent = Component.make("RouteComponent")(() => Effect.Do,
// Effect.bind("VMemoizedAsyncComponent", () => Component.useFC(MemoizedAsyncComponent)),
// Effect.bind("VAsyncComponent", () => Component.useFC(AsyncComponent)),
// Effect.let("input", () => React.useState("")),
// Effect.map(({ input: [input, setInput], VAsyncComponent, VMemoizedAsyncComponent }) =>
// <Flex direction="column" align="stretch" gap="2">
// <TextField.Root
// value={input}
// onChange={e => setInput(e.target.value)}
// />
// <VMemoizedAsyncComponent />
// <VAsyncComponent />
// </Flex>
// ),
// ).pipe(
// Component.withRuntime(runtime.context)
// )
class AsyncComponent extends Component.makeUntraced("AsyncComponent")(function*() {
const SubComponentFC = yield* SubComponent.use
yield* Effect.sleep("500 millis") // Async operation
// Cannot use React hooks after the async operation
return (
<Flex direction="column" align="stretch">
<Text>Rendered!</Text>
<SubComponentFC />
</Flex>
)
}).pipe(
Async.async,
Async.withOptions({ defaultFallback: <p>Loading...</p> }),
) {}
class MemoizedAsyncComponent extends Memoized.memoized(AsyncComponent) {}
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
const [state] = React.useState(yield* Component.useOnMount(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
return <Text>{state}</Text>
}) {}
export const Route = createFileRoute("/dev/async-rendering")({
component: RouteComponent
})