diff --git a/packages/effect-fc/src/Async.ts b/packages/effect-fc/src/Async.ts index af43edf..03b158d 100644 --- a/packages/effect-fc/src/Async.ts +++ b/packages/effect-fc/src/Async.ts @@ -1,31 +1,47 @@ /** 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 Component from "./Component.js" -export const TypeId: unique symbol = Symbol.for("@effect-fc/Async/Async") -export type TypeId = typeof TypeId +export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async") +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 { + /** + * 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 } +/** + * Props for `Async` components. + */ export type AsyncProps = Omit -export const AsyncPrototype = Object.freeze({ - [TypeId]: TypeId, +export const AsyncPrototype: AsyncPrototype = Object.freeze({ + [AsyncTypeId]: AsyncTypeId, asFunctionComponent

( this: Component.Component & Async, runtimeRef: React.RefObject>>, ) { - const SuspenseInner = (props: { readonly promise: Promise }) => React.use(props.promise) + const Inner = (props: { readonly promise: Promise }) => React.use(props.promise) return ({ fallback, name, ...props }: AsyncProps) => { const promise = Runtime.runPromise(runtimeRef.current)( @@ -38,17 +54,64 @@ export const AsyncPrototype = Object.freeze({ return React.createElement( React.Suspense, { fallback: fallback ?? this.defaultFallback, name }, - React.createElement(SuspenseInner, { promise }), + React.createElement(Inner, { promise }), ) } }, } 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 = ( + self: Record, + that: Record, +) => { + 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 = >( - self: T + self: T & ( + "promise" extends keyof Component.Component.Props + ? "The 'promise' prop name is restricted for Async components. Please rename the 'promise' prop to something else." + : T + ) ): ( & Omit> & Component.Component< @@ -59,13 +122,37 @@ export const async = >( > & Async ) => Object.setPrototypeOf( - Object.assign(function() {}, self), + Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }), Object.freeze(Object.setPrototypeOf( Object.assign({}, AsyncPrototype), 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:

Loading...

}), + * ) + * + * // Uncurried + * const MyAsyncComponent = Async.withOptions( + * Async.async(MyComponent), + * { defaultFallback:

Loading...

}, + * ) + * ``` + */ export const withOptions: { & Async>( options: Partial diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 8a21b5b..8c61e08 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -4,20 +4,16 @@ import { Context, type Duration, Effect, Equivalence, ExecutionStrategy, Exit, F import * as React from "react" -export const TypeId: unique symbol = Symbol.for("@effect-fc/Component/Component") -export type TypeId = typeof TypeId +export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component") +export type ComponentTypeId = typeof ComponentTypeId /** - * Interface representing an Effect-based React Component. - * - * This is both: - * - an Effect that produces a React function component - * - a constructor-like object with component metadata and options + * Represents an Effect-based React Component that integrates the Effect system with React. */ export interface Component

extends ComponentPrototype, ComponentOptions { new(_: never): Record - readonly [TypeId]: TypeId + readonly [ComponentTypeId]: ComponentTypeId readonly "~Props": P readonly "~Success": A readonly "~Error": E @@ -38,7 +34,7 @@ export declare namespace Component { export interface ComponentPrototype

extends Pipeable.Pipeable { - readonly [TypeId]: TypeId + readonly [ComponentTypeId]: ComponentTypeId readonly use: Effect.Effect<(props: P) => A, never, Exclude> asFunctionComponent( @@ -50,7 +46,7 @@ extends Pipeable.Pipeable { } export const ComponentPrototype: ComponentPrototype = Object.freeze({ - [TypeId]: TypeId, + [ComponentTypeId]: ComponentTypeId, ...Pipeable.Prototype, get use() { return use(this) }, @@ -92,39 +88,50 @@ const use = Effect.fnUntraced(function*

[] + + /** + * Specifies the execution strategy for finalizers when the component unmounts or its scope closes. + * Determines whether finalizers execute sequentially or in parallel. + * * @default ExecutionStrategy.sequential */ readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy /** - * Debounce time before executing finalizers after component unmount. - * Helps avoid unnecessary work during fast remount/remount cycles. + * Debounce duration before executing finalizers after component unmount. + * Prevents unnecessary cleanup work during rapid remount/unmount cycles, + * which is common in development and certain UI patterns. + * * @default "100 millis" */ readonly finalizerExecutionDebounce: Duration.DurationInput } export const defaultOptions: ComponentOptions = { + nonReactiveTags: [Tracer.ParentSpan], finalizerExecutionStrategy: ExecutionStrategy.sequential, 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, TypeId) +export const isComponent = (u: unknown): u is Component<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, ComponentTypeId) export declare namespace make { export type Gen = { @@ -352,17 +359,51 @@ export declare namespace make { } /** - * Creates an Effect-FC Component following the same overloads and pipeline style as `Effect.fn`. + * Creates an Effect-FC Component using the same overloads and pipeline composition style as `Effect.fn`. * - * This is the **recommended** way to define components. It supports: - * - Generator syntax (yield* style) — most ergonomic and readable - * - Direct Effect return (non-generator) - * - Chained transformation functions (like Effect.fn pipelines) - * - Optional tracing span with automatic `displayName` + * This is the **recommended** approach for defining Effect-FC components. It provides comprehensive + * support for multiple component definition patterns: * - * When you provide a `spanName` as the first argument, two things happen automatically: - * 1. A tracing span is created with that name (unless using `makeUntraced`) - * 2. The resulting React component gets `displayName = spanName` + * - **Generator syntax** (yield* style): Most ergonomic and readable approach for sequential operations + * - **Direct Effect return**: For simple components that return an Effect directly + * - **Chained transformation functions**: Enables Effect.fn-style pipelines for composable transformations + * - **Automatic tracing**: Optional tracing span creation with automatic `displayName` assignment + * + * When a `spanName` string is provided, the following occurs automatically: + * 1. A distributed tracing span is created with the specified name + * 2. The resulting React component receives `displayName = spanName` for DevTools visibility + * + * @example + * ```tsx + * const MyComponent = Component.make("MyComponent")(function* (props: { count: number }) { + * const value = yield* someEffect + * return

{value}
+ * }) + * ``` + * + * @example As an opaque type using class syntax + * ```tsx + * class MyComponent extends Component.make("MyComponent")(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) {} + * ``` + * + * @example Without name + * ```tsx + * class MyComponent extends Component.make(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) {} + * ``` + * + * @example Using pipeline + * ```tsx + * class MyComponent extends Component.make("MyComponent")( + * (props: { count: number }) => someEffect, + * Effect.map(value =>
{value}
), + * ) {} + * ``` */ export const make: ( & make.Gen @@ -393,15 +434,50 @@ export const make: ( } /** - * Same as `make`, but creates an **untraced** version — no automatic tracing span is created. + * Creates an Effect-FC Component without automatic distributed tracing. * - * Follows the exact same API shape as `Effect.fnUntraced`. - * Useful for: - * - Components where you want full manual control over tracing - * - Avoiding span noise in deeply nested UI + * This function provides the same API surface as `make`, but does not create automatic tracing spans. + * It follows the exact same overload structure as `Effect.fnUntraced`. * - * When a string is provided as first argument, it is **only** used as the React component's `displayName` - * (no tracing span is created). + * Use this variant when you need: + * - Full manual control over tracing instrumentation + * - To reduce tracing overhead in deeply nested component hierarchies + * - To avoid span noise in performance-sensitive applications + * + * When a `spanName` string is provided, it is used **exclusively** as the React component's + * `displayName` for DevTools identification. No tracing span is created. + * + * @example + * ```tsx + * const MyComponent = Component.makeUntraced("MyComponent")(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) + * ``` + * + * @example As an opaque type using class syntax + * ```tsx + * class MyComponent extends Component.makeUntraced("MyComponent")(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) {} + * ``` + * + * @example Without name + * ```tsx + * class MyComponent extends Component.makeUntraced(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) {} + * ``` + * + * @example Using pipeline + * ```tsx + * class MyComponent extends Component.makeUntraced("MyComponent")( + * (props: { count: number }) => someEffect, + * Effect.map(value =>
{value}
), + * ) {} + * ``` */ export const makeUntraced: ( & make.Gen @@ -425,7 +501,20 @@ export const makeUntraced: ( ) /** - * Creates a new component with modified options while preserving original behavior. + * Creates a new component with modified configuration options while preserving all original behavior. + * + * This function allows you to customize component-level options such as finalizer execution strategy + * and debounce timing. + * + * @example + * ```tsx + * const MyComponentWithCustomOptions = MyComponent.pipe( + * Component.withOptions({ + * finalizerExecutionStrategy: ExecutionStrategy.parallel, + * finalizerExecutionDebounce: "50 millis", + * }) + * ) + * ``` */ export const withOptions: { >( @@ -444,21 +533,27 @@ export const withOptions: { )) /** - * Wraps an Effect-FC `Component` and turns it into a regular React function component - * that serves as an **entrypoint** into an Effect-FC component hierarchy. + * Wraps an Effect-FC Component and converts it into a standard React function component, + * serving as an **entrypoint** into an Effect-FC component hierarchy. * - * This is the recommended way to connect Effect-FC components to the rest of your React app, - * especially when using routers (TanStack Router, React Router, etc.), lazy-loaded routes, - * or any place where a standard React component is expected. + * This is how Effect-FC components are integrated with the broader React ecosystem, + * particularly when: + * - Using client-side routers (TanStack Router, React Router, etc.) + * - Implementing lazy-loaded or code-split routes + * - Connecting to third-party libraries expecting standard React components + * - Creating component boundaries between Effect-FC and non-Effect-FC code * - * The runtime is obtained from the provided React Context, allowing you to: - * - Provide dependencies once at a high level - * - Use the same runtime across an entire route tree or feature + * The Effect runtime is obtained from the provided React Context. * - * @example Using TanStack Router + * @param self - The Effect-FC Component to be rendered as a standard React component + * @param context - React Context providing the Effect Runtime for this component tree. + * Create this using the `ReactRuntime` module. + * + * @example Integration with TanStack Router * ```tsx - * // Main + * // Application root * export const runtime = ReactRuntime.make(Layer.empty) + * * function App() { * return ( * @@ -467,14 +562,12 @@ export const withOptions: { * ) * } * - * // Route + * // Route definition * export const Route = createFileRoute("/")({ * component: Component.withRuntime(HomePage, runtime.context) * }) * ``` * - * @param self - The Effect-FC Component you want to render as a regular React component. - * @param context - React Context that holds the Runtime to use for this component tree. See the `ReactRuntime` module to create one. */ export const withRuntime: {

( @@ -496,8 +589,10 @@ export const withRuntime: { /** - * Service that keeps track of scopes associated with React components - * (used internally by the `useScope` hook). + * Internal Effect service that maintains a registry of scopes associated with React component instances. + * + * This service is used internally by the `useScope` hook to manage the lifecycle of component scopes, + * including tracking active scopes and coordinating their cleanup when components unmount or dependencies change. */ export class ScopeMap extends Effect.Service()("@effect-fc/Component/ScopeMap", { effect: Effect.bind(Effect.Do, "ref", () => Ref.make(HashMap.empty())) @@ -519,13 +614,22 @@ export declare namespace useScope { } /** - * Hook that creates and manages a `Scope` for the current component instance. + * Effect hook that creates and manages a `Scope` for the current component instance. * - * Automatically closes the scope whenever `deps` changes or the component unmounts. + * This hook establishes a new scope that is automatically closed when: + * - The component unmounts + * - The dependency array `deps` changes * - * @param deps - dependency array like in `React.useEffect` - * @param options - finalizer execution control - */ + * The scope provides a resource management boundary for any Effects executed within the component, + * ensuring proper cleanup of resources and execution of finalizers. + * + * @param deps - Dependency array following React.useEffect semantics. The scope is recreated + * whenever any dependency changes. + * @param options - Configuration for finalizer execution behavior, including execution strategy + * and debounce timing. + * + * @returns An Effect that produces a `Scope` for resource management +*/ export const useScope = Effect.fnUntraced(function*( deps: React.DependencyList, options?: useScope.Options, @@ -579,7 +683,23 @@ export const useScope = Effect.fnUntraced(function*( }) /** - * Runs an effect and returns its result only once on component mount. + * Effect hook that executes an Effect once when the component mounts and caches the result. + * + * This hook is useful for one-time initialization logic that should not be re-executed + * when the component re-renders. The Effect is executed exactly once during the component's + * initial mount, and the cached result is returned on all subsequent renders. + * + * @param f - A function that returns the Effect to execute on mount + * + * @returns An Effect that produces the cached result of the Effect + * + * @example + * ```tsx + * const MyComponent = Component.make(function*() { + * const initialData = yield* Component.useOnMount(() => getData) + * return

{initialData}
+ * }) + * ``` */ export const useOnMount = Effect.fnUntraced(function* ( f: () => Effect.Effect @@ -593,9 +713,33 @@ export declare namespace useOnChange { } /** - * Runs an effect and returns its result whenever dependencies change. + * Effect hook that executes an Effect whenever dependencies change and caches the result. * - * Provides its own `Scope` which closes whenever `deps` changes or the component unmounts. + * This hook combines the dependency-tracking behavior of React.useEffect with Effect caching. + * The Effect is re-executed whenever any dependency in the `deps` array changes, and the result + * is cached until the next dependency change. + * + * A dedicated scope is created for each dependency change, ensuring proper resource cleanup: + * - The scope closes when dependencies change + * - The scope closes when the component unmounts + * - All finalizers are executed according to the configured execution strategy + * + * @param f - A function that returns the Effect to execute + * @param deps - Dependency array following React.useEffect semantics + * @param options - Configuration for scope and finalizer behavior + * + * @returns An Effect that produces the cached result of the Effect + * + * @example + * ```tsx + * const MyComponent = Component.make(function* (props: { userId: string }) { + * const userData = yield* Component.useOnChange( + * getUser(props.userId), + * [props.userId], + * ) + * return
{userData.name}
+ * }) + * ``` */ export const useOnChange = Effect.fnUntraced(function* ( f: () => Effect.Effect, @@ -619,9 +763,36 @@ export declare namespace useReactEffect { } /** - * Like `React.useEffect` but accepts an effect. + * Effect hook that provides Effect-based semantics for React.useEffect. * - * Cleanup logic is handled through the `Scope` API rather than using imperative cleanup. + * This hook bridges React's useEffect with the Effect system, allowing you to use Effects + * for React side effects while maintaining React's dependency tracking and lifecycle semantics. + * + * Unlike React.useEffect which uses imperative cleanup functions, this hook leverages the + * Effect Scope API for resource management. Cleanup logic is expressed declaratively through + * finalizers registered with the scope, providing better composability and error handling. + * + * @param f - A function that returns an Effect to execute as a side effect + * @param deps - Optional dependency array following React.useEffect semantics. + * If omitted, the effect runs after every render. + * @param options - Configuration for finalizer execution mode (sync or fork) and strategy + * + * @returns An Effect that produces void + * + * @example + * ```tsx + * const MyComponent = Component.make(function* (props: { id: string }) { + * yield* Component.useReactEffect( + * () => getNotificationStreamForUser(props.id).pipe( + * Stream.unwrap, + * Stream.runForEach(notification => Console.log(`Notification received: ${ notification }`), + * Effect.forkScoped, + * ), + * [props.id], + * ) + * return
Subscribed to notifications for {props.id}
+ * }) + * ``` */ export const useReactEffect = Effect.fnUntraced(function* ( f: () => Effect.Effect, @@ -660,9 +831,43 @@ export declare namespace useReactLayoutEffect { } /** - * Like `React.useReactLayoutEffect` but accepts an effect. + * Effect hook that provides Effect-based semantics for React.useLayoutEffect. * - * Cleanup logic is handled through the `Scope` API rather than using imperative cleanup. + * This hook is identical to `useReactEffect` but executes synchronously after DOM mutations + * but before the browser paints, following React.useLayoutEffect semantics. + * + * Use this hook when you need to: + * - Measure DOM elements (e.g., for layout calculations) + * - Synchronously update state based on DOM measurements + * - Avoid visual flicker from asynchronous updates + * + * Like `useReactEffect`, cleanup logic is handled through the Effect Scope API rather than + * imperative cleanup functions, providing declarative and composable resource management. + * + * @param f - A function that returns an Effect to execute as a layout side effect + * @param deps - Optional dependency array following React.useLayoutEffect semantics. + * If omitted, the effect runs after every render. + * @param options - Configuration for finalizer execution mode (sync or fork) and strategy + * + * @returns An Effect that produces void + * + * @example + * ```tsx + * const MyComponent = Component.make(function*() { + * const ref = React.useRef(null) + * yield* Component.useReactLayoutEffect( + * () => Effect.gen(function* () { + * const element = ref.current + * if (element) { + * const rect = element.getBoundingClientRect() + * yield* Console.log(`Element dimensions: ${ rect.width }x${ rect.height }`) + * } + * }), + * [], + * ) + * return
Content
+ * }) + * ``` */ export const useReactLayoutEffect = Effect.fnUntraced(function* ( f: () => Effect.Effect, @@ -675,7 +880,23 @@ export const useReactLayoutEffect = Effect.fnUntraced(function* ( }) /** - * Get a synchronous run function for the current runtime context. + * Effect hook that provides a synchronous function to execute Effects within the current runtime context. + * + * This hook returns a function that can execute Effects synchronously, blocking until completion. + * Use this when you need to run Effects from non-Effect code (e.g., event handlers, callbacks) + * within a component. + * + * @returns An Effect that produces a function capable of synchronously executing Effects + * + * @example + * ```tsx + * const MyComponent = Component.make(function*() { + * const runSync = yield* Component.useRunSync() // Specify required services + * const runSync = yield* Component.useRunSync() // Or no service requirements + * + * return + * }) + * ``` */ export const useRunSync = (): Effect.Effect< (effect: Effect.Effect) => A, @@ -684,7 +905,23 @@ export const useRunSync = (): Effect.Effect< > => Effect.andThen(Effect.runtime(), Runtime.runSync) /** - * Get a Promise-based run function for the current runtime context. + * Effect hook that provides an asynchronous function to execute Effects within the current runtime context. + * + * This hook returns a function that executes Effects asynchronously, returning a Promise that resolves + * with the Effect's result. Use this when you need to run Effects from non-Effect code (e.g., event handlers, + * async callbacks) and want to handle the result asynchronously. + * + * @returns An Effect that produces a function capable of asynchronously executing Effects + * + * @example + * ```tsx + * const MyComponent = Component.make(function*() { + * const runPromise = yield* Component.useRunPromise() // Specify required services + * const runPromise = yield* Component.useRunPromise() // Or no service requirements + * + * return + * }) + * ``` */ export const useRunPromise = (): Effect.Effect< (effect: Effect.Effect) => Promise, @@ -693,7 +930,32 @@ export const useRunPromise = (): Effect.Effect< > => Effect.andThen(Effect.runtime(), context => Runtime.runPromise(context)) /** - * Turns a function returning an effect into a memoized synchronous function. + * Effect hook that memoizes a function that returns an Effect, providing synchronous execution. + * + * This hook wraps a function that returns an Effect and returns a memoized version that: + * - Executes the Effect synchronously when called + * - Is memoized based on the provided dependency array + * - Maintains referential equality across renders when dependencies don't change + * + * Use this to create stable callback references for event handlers and other scenarios + * where you need to execute Effects synchronously from non-Effect code. + * + * @param f - A function that accepts arguments and returns an Effect + * @param deps - Dependency array. The memoized function is recreated when dependencies change. + * + * @returns An Effect that produces a memoized function with the same signature as `f` + * + * @example + * ```tsx + * const MyComponent = Component.make(function* (props: { onSave: (data: Data) => void }) { + * const handleSave = yield* Component.useCallbackSync( + * (data: Data) => Effect.sync(() => props.onSave(data)), + * [props.onSave], + * ) + * + * return + * }) + * ``` */ export const useCallbackSync = Effect.fnUntraced(function* ( f: (...args: Args) => Effect.Effect, @@ -708,7 +970,32 @@ export const useCallbackSync = Effect.fnUntraced(function* void }) { + * const handleSave = yield* Component.useCallbackPromise( + * (data: Data) => Effect.promise(() => props.onSave(data)), + * [props.onSave], + * ) + * + * return + * }) + * ``` */ export const useCallbackPromise = Effect.fnUntraced(function* ( f: (...args: Args) => Effect.Effect, @@ -727,16 +1014,70 @@ export declare namespace useContext { } /** - * Hook that constructs a layer and returns the created context. + * Effect hook that constructs an Effect Layer and returns the resulting context. * - * The layer gets reconstructed everytime `layer` changes, so make sure its value is stable. + * This hook creates a managed runtime from the provided layer and returns the context it produces. + * The layer is reconstructed whenever its value changes, so ensure the layer reference is stable + * (typically by memoizing it or defining it outside the component). * - * Building a layer containing asynchronous effects require the component calling this hook to be made async using `Async.async`. + * The hook automatically manages the layer's lifecycle: + * - The layer is built when the component mounts or when the layer reference changes + * - Resources are properly released when the component unmounts or dependencies change + * - Finalizers are executed according to the configured execution strategy + * + * @param layer - The Effect Layer to construct. Should be a stable reference to avoid unnecessary + * reconstruction. Consider memoizing with React.useMemo if defined inline. + * @param options - Configuration for scope and finalizer behavior + * + * @returns An Effect that produces the context created by the layer + * + * @throws If the layer contains asynchronous effects, the component must be wrapped with `Async.async` + * + * @example + * ```tsx + * const MyLayer = Layer.succeed(MyService, new MyServiceImpl()) + * const MyComponent = Component.make(function*() { + * const context = yield* Component.useContextFromLayer(MyLayer) + * const Sub = yield* SubComponent.use.pipe( + * Effect.provide(context) + * ) + * + * return + * }) + * ``` + * + * @example With memoized layer + * ```tsx + * const MyComponent = Component.make(function*(props: { id: string })) { + * const context = yield* Component.useContextFromLayer( + * React.useMemo(() => Layer.succeed(MyService, new MyServiceImpl(props.id)), [props.id]) + * ) + * const Sub = yield* SubComponent.use.pipe( + * Effect.provide(context) + * ) + * + * return + * }) + * ``` + * + * @example With async layer + * ```tsx + * const MyAsyncLayer = Layer.effect(MyService, someAsyncEffect) + * const MyComponent = Component.make(function*() { + * const context = yield* Component.useContextFromLayer(MyAsyncLayer) + * const Sub = yield* SubComponent.use.pipe( + * Effect.provide(context) + * ) + * + * return + * }).pipe( + * Async.async // Required to handle async layer effects + * ) */ -export const useContext = ( +export const useContextFromLayer = ( layer: Layer.Layer, options?: useContext.Options, -): Effect.Effect, E, Exclude> => useOnChange(() => Effect.context().pipe( +): Effect.Effect, E, RIn | Scope.Scope> => 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), diff --git a/packages/effect-fc/src/ErrorObserver.ts b/packages/effect-fc/src/ErrorObserver.ts index 30f5424..75930af 100644 --- a/packages/effect-fc/src/ErrorObserver.ts +++ b/packages/effect-fc/src/ErrorObserver.ts @@ -1,20 +1,20 @@ 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 type TypeId = typeof TypeId +export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver") +export type ErrorObserverTypeId = typeof ErrorObserverTypeId export interface ErrorObserver extends Pipeable.Pipeable { - readonly [TypeId]: TypeId + readonly [ErrorObserverTypeId]: ErrorObserverTypeId handle(effect: Effect.Effect): Effect.Effect readonly subscribe: Effect.Effect>, never, Scope.Scope> } export const ErrorObserver = (): Context.Tag> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver") -class ErrorObserverImpl +export class ErrorObserverImpl extends Pipeable.Class() implements ErrorObserver { - readonly [TypeId]: TypeId = TypeId + readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId readonly subscribe: Effect.Effect>, never, Scope.Scope> constructor( @@ -29,7 +29,7 @@ extends Pipeable.Class() implements ErrorObserver { } } -class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor { +export class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor { readonly value = Effect.void constructor(readonly pubsub: PubSub.PubSub>) { super() @@ -43,7 +43,7 @@ class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor { } -export const isErrorObserver = (u: unknown): u is ErrorObserver => Predicate.hasProperty(u, TypeId) +export const isErrorObserver = (u: unknown): u is ErrorObserver => Predicate.hasProperty(u, ErrorObserverTypeId) export const layer: Layer.Layer = Layer.unwrapEffect(Effect.map( PubSub.unbounded>(), diff --git a/packages/effect-fc/src/Memoized.ts b/packages/effect-fc/src/Memoized.ts index 33be421..804d59f 100644 --- a/packages/effect-fc/src/Memoized.ts +++ b/packages/effect-fc/src/Memoized.ts @@ -4,20 +4,38 @@ import * as React from "react" import type * as Component from "./Component.js" -export const TypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized") -export type TypeId = typeof TypeId +export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized") +export type MemoizedTypeId = typeof MemoizedTypeId -export interface Memoized

extends MemoizedOptions

{ - 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

extends MemoizedPrototype, MemoizedOptions

{} + +export interface MemoizedPrototype { + readonly [MemoizedTypeId]: MemoizedTypeId } +/** + * Configuration options for Memoized components. + * + * @template P The props type of the component + */ export interface MemoizedOptions

{ + /** + * 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

} -export const MemoizedPrototype = Object.freeze({ - [TypeId]: TypeId, +export const MemoizedPrototype: MemoizedPrototype = Object.freeze({ + [MemoizedTypeId]: MemoizedTypeId, transformFunctionComponent

( this: Memoized

, @@ -28,8 +46,21 @@ export const MemoizedPrototype = Object.freeze({ } as const) -export const isMemoized = (u: unknown): u is Memoized => Predicate.hasProperty(u, TypeId) +export const isMemoized = (u: unknown): u is Memoized => 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 = >( self: T ): T & Memoized> => Object.setPrototypeOf( @@ -40,6 +71,30 @@ export const memoized = >( )), ) +/** + * 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: { & Memoized>( options: Partial>> diff --git a/packages/effect-fc/src/Query.ts b/packages/effect-fc/src/Query.ts index 9c306b7..78ccc0b 100644 --- a/packages/effect-fc/src/Query.ts +++ b/packages/effect-fc/src/Query.ts @@ -6,12 +6,12 @@ import * as Result from "./Result.js" export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query") export type QueryTypeId = typeof QueryTypeId -export interface Query +export interface Query extends Pipeable.Pipeable { readonly [QueryTypeId]: QueryTypeId - readonly context: Context.Context - readonly key: Stream.Stream + readonly context: Context.Context + readonly key: Stream.Stream readonly f: (key: K) => Effect.Effect readonly initialProgress: P @@ -37,13 +37,13 @@ export declare namespace Query { export type AnyKey = readonly any[] } -export class QueryImpl -extends Pipeable.Class() implements Query { +export class QueryImpl +extends Pipeable.Class() implements Query { readonly [QueryTypeId]: QueryTypeId = QueryTypeId constructor( - readonly context: Context.Context, - readonly key: Stream.Stream, + readonly context: Context.Context, + readonly key: Stream.Stream, readonly f: (key: K) => Effect.Effect, readonly initialProgress: P, @@ -77,6 +77,7 @@ extends Pipeable.Class() implements Query { ], { concurrency: "unbounded" }).pipe( Effect.ignore, this.runSemaphore.withPermits(1), + Effect.provide(this.context), ) } @@ -265,11 +266,11 @@ extends Pipeable.Class() implements Query { } } -export const isQuery = (u: unknown): u is Query => Predicate.hasProperty(u, QueryTypeId) +export const isQuery = (u: unknown): u is Query => Predicate.hasProperty(u, QueryTypeId) export declare namespace make { - export interface Options { - readonly key: Stream.Stream + export interface Options { + readonly key: Stream.Stream readonly f: (key: NoInfer) => Effect.Effect>> readonly initialProgress?: P readonly staleTime?: Duration.DurationInput @@ -277,17 +278,17 @@ export declare namespace make { } } -export const make = Effect.fnUntraced(function* ( - options: make.Options +export const make = Effect.fnUntraced(function* ( + options: make.Options ): Effect.fn.Return< - Query, P>, + Query, P>, never, - Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext + Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext > { const client = yield* QueryClient.QueryClient - return new QueryImpl( - yield* Effect.context>(), + return new QueryImpl, P>( + yield* Effect.context>(), options.key, options.f as any, options.initialProgress as P, @@ -304,12 +305,12 @@ export const make = Effect.fnUntraced(function* ( - options: make.Options +export const service = ( + options: make.Options ): Effect.Effect< - Query, P>, + Query, P>, never, - Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext + Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext > => Effect.tap( make(options), query => Effect.forkScoped(query.run), diff --git a/packages/effect-fc/src/Result.ts b/packages/effect-fc/src/Result.ts index 3cef66c..a47f747 100644 --- a/packages/effect-fc/src/Result.ts +++ b/packages/effect-fc/src/Result.ts @@ -240,16 +240,16 @@ export const unsafeForkEffect = ( Effect.provide(Layer.empty.pipe( Layer.provideMerge(makeProgressLayer()), Layer.provideMerge(Layer.succeed(State(), { - get: ref, + get: Ref.get(ref), set: v => Effect.andThen(Ref.set(ref, v), PubSub.publish(pubsub, v)) })), )), ))), Effect.map(({ ref, pubsub, fiber }) => [ Subscribable.make({ - get: ref, + get: Ref.get(ref), 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), )), }), diff --git a/packages/example/src/lib/form/TextFieldFormInput.tsx b/packages/example/src/lib/form/TextFieldFormInputView.tsx similarity index 95% rename from packages/example/src/lib/form/TextFieldFormInput.tsx rename to packages/example/src/lib/form/TextFieldFormInputView.tsx index b3ba633..052348c 100644 --- a/packages/example/src/lib/form/TextFieldFormInput.tsx +++ b/packages/example/src/lib/form/TextFieldFormInputView.tsx @@ -18,7 +18,7 @@ extends Omit, Form.useOptional export type TextFieldFormInputProps = Props | OptionalProps -export class TextFieldFormInput extends Component.makeUntraced("TextFieldFormInput")(function*(props: TextFieldFormInputProps) { +export class TextFieldFormInputView extends Component.make("TextFieldFormInputView")(function*(props: TextFieldFormInputProps) { const input: ( | { readonly optional: true } & Form.useOptionalInput.Success | { readonly optional: false } & Form.useInput.Success diff --git a/packages/example/src/routeTree.gen.ts b/packages/example/src/routeTree.gen.ts index 779c018..0e6de17 100644 --- a/packages/example/src/routeTree.gen.ts +++ b/packages/example/src/routeTree.gen.ts @@ -13,10 +13,10 @@ import { Route as ResultRouteImport } from './routes/result' import { Route as QueryRouteImport } from './routes/query' import { Route as FormRouteImport } from './routes/form' import { Route as BlankRouteImport } from './routes/blank' +import { Route as AsyncRouteImport } from './routes/async' import { Route as IndexRouteImport } from './routes/index' import { Route as DevMemoRouteImport } from './routes/dev/memo' import { Route as DevContextRouteImport } from './routes/dev/context' -import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering' const ResultRoute = ResultRouteImport.update({ id: '/result', @@ -38,6 +38,11 @@ const BlankRoute = BlankRouteImport.update({ path: '/blank', getParentRoute: () => rootRouteImport, } as any) +const AsyncRoute = AsyncRouteImport.update({ + id: '/async', + path: '/async', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -53,40 +58,35 @@ const DevContextRoute = DevContextRouteImport.update({ path: '/dev/context', getParentRoute: () => rootRouteImport, } as any) -const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({ - id: '/dev/async-rendering', - path: '/dev/async-rendering', - getParentRoute: () => rootRouteImport, -} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/async': typeof AsyncRoute '/blank': typeof BlankRoute '/form': typeof FormRoute '/query': typeof QueryRoute '/result': typeof ResultRoute - '/dev/async-rendering': typeof DevAsyncRenderingRoute '/dev/context': typeof DevContextRoute '/dev/memo': typeof DevMemoRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/async': typeof AsyncRoute '/blank': typeof BlankRoute '/form': typeof FormRoute '/query': typeof QueryRoute '/result': typeof ResultRoute - '/dev/async-rendering': typeof DevAsyncRenderingRoute '/dev/context': typeof DevContextRoute '/dev/memo': typeof DevMemoRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/async': typeof AsyncRoute '/blank': typeof BlankRoute '/form': typeof FormRoute '/query': typeof QueryRoute '/result': typeof ResultRoute - '/dev/async-rendering': typeof DevAsyncRenderingRoute '/dev/context': typeof DevContextRoute '/dev/memo': typeof DevMemoRoute } @@ -94,42 +94,42 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/async' | '/blank' | '/form' | '/query' | '/result' - | '/dev/async-rendering' | '/dev/context' | '/dev/memo' fileRoutesByTo: FileRoutesByTo to: | '/' + | '/async' | '/blank' | '/form' | '/query' | '/result' - | '/dev/async-rendering' | '/dev/context' | '/dev/memo' id: | '__root__' | '/' + | '/async' | '/blank' | '/form' | '/query' | '/result' - | '/dev/async-rendering' | '/dev/context' | '/dev/memo' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AsyncRoute: typeof AsyncRoute BlankRoute: typeof BlankRoute FormRoute: typeof FormRoute QueryRoute: typeof QueryRoute ResultRoute: typeof ResultRoute - DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute DevContextRoute: typeof DevContextRoute DevMemoRoute: typeof DevMemoRoute } @@ -164,6 +164,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BlankRouteImport parentRoute: typeof rootRouteImport } + '/async': { + id: '/async' + path: '/async' + fullPath: '/async' + preLoaderRoute: typeof AsyncRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -185,23 +192,16 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DevContextRouteImport 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 = { IndexRoute: IndexRoute, + AsyncRoute: AsyncRoute, BlankRoute: BlankRoute, FormRoute: FormRoute, QueryRoute: QueryRoute, ResultRoute: ResultRoute, - DevAsyncRenderingRoute: DevAsyncRenderingRoute, DevContextRoute: DevContextRoute, DevMemoRoute: DevMemoRoute, } diff --git a/packages/example/src/routes/async.tsx b/packages/example/src/routes/async.tsx new file mode 100644 index 0000000..62e0234 --- /dev/null +++ b/packages/example/src/routes/async.tsx @@ -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 ( +

+ {post.title} + {post.body} +
+ ) +}).pipe( + Async.async, + Async.withOptions({ defaultFallback: Default fallback }), + 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 ( + + + setText(e.currentTarget.value)} + /> + + + + Loading post...} /> + + + ) +}).pipe( + Component.withRuntime(runtime.context) +) + +export const Route = createFileRoute("/async")({ + component: AsyncRouteComponent, +}) diff --git a/packages/example/src/routes/dev/async-rendering.tsx b/packages/example/src/routes/dev/async-rendering.tsx deleted file mode 100644 index c057745..0000000 --- a/packages/example/src/routes/dev/async-rendering.tsx +++ /dev/null @@ -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 ( - - setInput(e.target.value)} - /> - -

Loading memoized...

, [])} /> - -
- ) -}).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 }) => -// -// setInput(e.target.value)} -// /> - -// -// -// -// ), -// ).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 ( - - Rendered! - - - ) -}).pipe( - Async.async, - Async.withOptions({ defaultFallback:

Loading...

}), -) {} -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 {state} -}) {} - -export const Route = createFileRoute("/dev/async-rendering")({ - component: RouteComponent -}) diff --git a/packages/example/src/routes/dev/context.tsx b/packages/example/src/routes/dev/context.tsx index b1212c5..26048a9 100644 --- a/packages/example/src/routes/dev/context.tsx +++ b/packages/example/src/routes/dev/context.tsx @@ -23,7 +23,7 @@ const SubComponent = Component.makeUntraced("SubComponent")(function*() { const ContextView = Component.makeUntraced("ContextView")(function*() { const [serviceValue, setServiceValue] = React.useState("test") const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue]) - const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useContext(SubServiceLayer)) + const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useContextFromLayer(SubServiceLayer)) return ( diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index 3060dca..d8ba6d9 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -2,7 +2,7 @@ import { Button, Container, Flex, Text } from "@radix-ui/themes" import { createFileRoute } from "@tanstack/react-router" import { Console, Effect, Match, Option, ParseResult, Schema } from "effect" import { Component, Form, Subscribable } from "effect-fc" -import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput" +import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView" import { DateTimeUtcFromZonedInput } from "@/lib/schema" import { runtime } from "@/runtime" @@ -38,7 +38,7 @@ const RegisterFormSubmitSchema = Schema.Struct({ birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf), }) -class RegisterForm extends Effect.Service()("RegisterForm", { +class RegisterFormService extends Effect.Service()("RegisterFormService", { scoped: Form.service({ schema: RegisterFormSchema.pipe( Schema.compose( @@ -62,15 +62,15 @@ class RegisterForm extends Effect.Service()("RegisterForm", { }) }) {} -class RegisterFormView extends Component.makeUntraced("RegisterFormView")(function*() { - const form = yield* RegisterForm +class RegisterFormView extends Component.make("RegisterFormView")(function*() { + const form = yield* RegisterFormService const [canSubmit, submitResult] = yield* Subscribable.useSubscribables([ form.canSubmit, form.mutation.result, ]) const runPromise = yield* Component.useRunPromise() - const TextFieldFormInputFC = yield* TextFieldFormInput.use + const TextFieldFormInput = yield* TextFieldFormInputView.use yield* Component.useOnMount(() => Effect.gen(function*() { yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted")) @@ -85,15 +85,15 @@ class RegisterFormView extends Component.makeUntraced("RegisterFormView")(functi void runPromise(form.submit) }}> - - - + return }).pipe( Component.withRuntime(runtime.context) ) diff --git a/packages/example/src/routes/index.tsx b/packages/example/src/routes/index.tsx index cd9ffa8..d747b39 100644 --- a/packages/example/src/routes/index.tsx +++ b/packages/example/src/routes/index.tsx @@ -2,19 +2,19 @@ import { createFileRoute } from "@tanstack/react-router" import { Effect } from "effect" import { Component } from "effect-fc" import { runtime } from "@/runtime" -import { Todos } from "@/todo/Todos" -import { TodosState } from "@/todo/TodosState.service" +import { TodosState } from "@/todo/TodosState" +import { TodosView } from "@/todo/TodosView" const TodosStateLive = TodosState.Default("todos") -const Index = Component.makeUntraced("Index")(function*() { - const TodosFC = yield* Effect.provide( - Todos.use, - yield* Component.useContext(TodosStateLive), +const Index = Component.make("IndexView")(function*() { + const Todos = yield* Effect.provide( + TodosView.use, + yield* Component.useContextFromLayer(TodosStateLive), ) - return + return }).pipe( Component.withRuntime(runtime.context) ) diff --git a/packages/example/src/routes/query.tsx b/packages/example/src/routes/query.tsx index 2092c0c..a6e2f4d 100644 --- a/packages/example/src/routes/query.tsx +++ b/packages/example/src/routes/query.tsx @@ -13,15 +13,16 @@ const Post = Schema.Struct({ body: Schema.String, }) -const ResultView = Component.makeUntraced("Result")(function*() { +const ResultView = Component.make("ResultView")(function*() { const runPromise = yield* Component.useRunPromise() const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() { const idRef = yield* SubscriptionRef.make(1) + const key = Stream.map(idRef.changes, id => [id] as const) const query = yield* Query.service({ - key: Stream.zipLatest(Stream.make("posts" as const), idRef.changes), - f: ([, id]) => HttpClient.HttpClient.pipe( + key, + f: ([id]) => HttpClient.HttpClient.pipe( Effect.tap(Effect.sleep("500 millis")), Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), Effect.andThen(response => response.json), diff --git a/packages/example/src/todo/Todo.tsx b/packages/example/src/todo/TodoView.tsx similarity index 92% rename from packages/example/src/todo/Todo.tsx rename to packages/example/src/todo/TodoView.tsx index a26a511..4086c5b 100644 --- a/packages/example/src/todo/Todo.tsx +++ b/packages/example/src/todo/TodoView.tsx @@ -5,9 +5,9 @@ import { Component, Form, Subscribable } from "effect-fc" import { FaArrowDown, FaArrowUp } from "react-icons/fa" import { FaDeleteLeft } from "react-icons/fa6" import * as Domain from "@/domain" -import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput" +import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView" import { DateTimeUtcFromZonedInput } from "@/lib/schema" -import { TodosState } from "./TodosState.service" +import { TodosState } from "./TodosState" const TodoFormSchema = Schema.compose(Schema.Struct({ @@ -30,7 +30,7 @@ export type TodoProps = ( | { readonly _tag: "edit", readonly id: string } ) -export class Todo extends Component.makeUntraced("Todo")(function*(props: TodoProps) { +export class TodoView extends Component.make("TodoView")(function*(props: TodoProps) { const state = yield* TodosState const [ @@ -83,17 +83,17 @@ export class Todo extends Component.makeUntraced("Todo")(function*(props: TodoPr const runSync = yield* Component.useRunSync() const runPromise = yield* Component.useRunPromise() - const TextFieldFormInputFC = yield* TextFieldFormInput.use + const TextFieldFormInput = yield* TextFieldFormInputView.use return ( - + - Console.log("Todos unmounted")), )) - const TodoFC = yield* Todo.use + const Todo = yield* TodoView.use return ( Todos - + {Chunk.map(todos, todo => - + )}