diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 39bd6dd..8942f57 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -408,7 +408,7 @@ export const withRuntime: { export class ScopeMap extends Effect.Service()("effect-fc/Component/ScopeMap", { - effect: Effect.bind(Effect.Do, "ref", () => Ref.make(HashMap.empty())) + effect: Effect.bind(Effect.Do, "ref", () => Ref.make(HashMap.empty())) }) {} export namespace ScopeMap { @@ -429,25 +429,27 @@ export const useScope: { const runtimeRef = React.useRef>(null!) runtimeRef.current = yield* Effect.runtime() - const key = React.useId() const scopeMap = yield* ScopeMap as unknown as Effect.Effect - const scope = React.useMemo(() => Runtime.runSync(runtimeRef.current)(Effect.andThen( - scopeMap.ref, - map => Option.match(HashMap.get(map, key), { - onSome: entry => Effect.succeed(entry.scope), - onNone: () => Effect.tap( - Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential), - scope => Ref.update(scopeMap.ref, HashMap.set(key, { - scope, - closeFiber: Option.none(), - })) - ), - }), + const [key, scope] = React.useMemo(() => Runtime.runSync(runtimeRef.current)(Effect.andThen( + Effect.all([Effect.succeed({}), scopeMap.ref]), + ([key, map]) => Effect.andThen( + Option.match(HashMap.get(map, key), { + onSome: entry => Effect.succeed(entry.scope), + onNone: () => Effect.tap( + Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential), + scope => Ref.update(scopeMap.ref, HashMap.set(key, { + scope, + closeFiber: Option.none(), + })) + ), + }), + scope => [key, scope] as const, + ), // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList )), deps) - // biome-ignore lint/correctness/useExhaustiveDependencies: only reactive on "scope" + // biome-ignore lint/correctness/useExhaustiveDependencies: only reactive on "key" React.useEffect(() => Runtime.runSync(runtimeRef.current)(scopeMap.ref.pipe( Effect.andThen(HashMap.get(key)), Effect.tap(entry => Option.match(entry.closeFiber, { @@ -469,7 +471,7 @@ export const useScope: { })), )) ), - )), [scope]) + )), [key]) return scope }) diff --git a/packages/example/src/routeTree.gen.ts b/packages/example/src/routeTree.gen.ts index 4b9144d..b842211 100644 --- a/packages/example/src/routeTree.gen.ts +++ b/packages/example/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as FormRouteImport } from './routes/form' import { Route as BlankRouteImport } from './routes/blank' 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 FormRoute = FormRouteImport.update({ @@ -35,6 +36,11 @@ const DevMemoRoute = DevMemoRouteImport.update({ path: '/dev/memo', getParentRoute: () => rootRouteImport, } as any) +const DevContextRoute = DevContextRouteImport.update({ + id: '/dev/context', + path: '/dev/context', + getParentRoute: () => rootRouteImport, +} as any) const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({ id: '/dev/async-rendering', path: '/dev/async-rendering', @@ -46,6 +52,7 @@ export interface FileRoutesByFullPath { '/blank': typeof BlankRoute '/form': typeof FormRoute '/dev/async-rendering': typeof DevAsyncRenderingRoute + '/dev/context': typeof DevContextRoute '/dev/memo': typeof DevMemoRoute } export interface FileRoutesByTo { @@ -53,6 +60,7 @@ export interface FileRoutesByTo { '/blank': typeof BlankRoute '/form': typeof FormRoute '/dev/async-rendering': typeof DevAsyncRenderingRoute + '/dev/context': typeof DevContextRoute '/dev/memo': typeof DevMemoRoute } export interface FileRoutesById { @@ -61,19 +69,33 @@ export interface FileRoutesById { '/blank': typeof BlankRoute '/form': typeof FormRoute '/dev/async-rendering': typeof DevAsyncRenderingRoute + '/dev/context': typeof DevContextRoute '/dev/memo': typeof DevMemoRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/blank' | '/form' | '/dev/async-rendering' | '/dev/memo' + fullPaths: + | '/' + | '/blank' + | '/form' + | '/dev/async-rendering' + | '/dev/context' + | '/dev/memo' fileRoutesByTo: FileRoutesByTo - to: '/' | '/blank' | '/form' | '/dev/async-rendering' | '/dev/memo' + to: + | '/' + | '/blank' + | '/form' + | '/dev/async-rendering' + | '/dev/context' + | '/dev/memo' id: | '__root__' | '/' | '/blank' | '/form' | '/dev/async-rendering' + | '/dev/context' | '/dev/memo' fileRoutesById: FileRoutesById } @@ -82,6 +104,7 @@ export interface RootRouteChildren { BlankRoute: typeof BlankRoute FormRoute: typeof FormRoute DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute + DevContextRoute: typeof DevContextRoute DevMemoRoute: typeof DevMemoRoute } @@ -115,6 +138,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DevMemoRouteImport parentRoute: typeof rootRouteImport } + '/dev/context': { + id: '/dev/context' + path: '/dev/context' + fullPath: '/dev/context' + preLoaderRoute: typeof DevContextRouteImport + parentRoute: typeof rootRouteImport + } '/dev/async-rendering': { id: '/dev/async-rendering' path: '/dev/async-rendering' @@ -130,6 +160,7 @@ const rootRouteChildren: RootRouteChildren = { BlankRoute: BlankRoute, FormRoute: FormRoute, DevAsyncRenderingRoute: DevAsyncRenderingRoute, + DevContextRoute: DevContextRoute, DevMemoRoute: DevMemoRoute, } export const routeTree = rootRouteImport diff --git a/packages/example/src/routes/dev/context.tsx b/packages/example/src/routes/dev/context.tsx new file mode 100644 index 0000000..f512353 --- /dev/null +++ b/packages/example/src/routes/dev/context.tsx @@ -0,0 +1,42 @@ +import { Container, Flex, Text, TextField } from "@radix-ui/themes" +import { createFileRoute } from "@tanstack/react-router" +import { Console, Effect } from "effect" +import { Component } from "effect-fc" +import * as React from "react" +import { runtime } from "@/runtime" + + +class SubService extends Effect.Service()("SubService", { + effect: (value: string) => Effect.succeed({ value }) +}) {} + +const SubComponent = Component.makeUntraced("SubComponent")(function*() { + const service = yield* SubService + yield* Component.useOnMount(() => Effect.gen(function*() { + yield* Effect.addFinalizer(() => Console.log("SubComponent unmounted")) + yield* Console.log("SubComponent mounted") + })) + + return {service.value} +}) + +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, yield* Component.useContext(SubServiceLayer)) + + return ( + + + setServiceValue(e.target.value)} /> + + + + ) +}).pipe( + Component.withRuntime(runtime.context) +) + +export const Route = createFileRoute("/dev/context")({ + component: ContextView +})