From faac0215272c9e020266078dfe596631fb834441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Thu, 23 Jul 2026 04:58:41 +0200 Subject: [PATCH] Fix Async --- packages/effect-fc-next/src/Component.ts | 12 +++- packages/effect-fc-next/test/Async.test.tsx | 63 +++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 packages/effect-fc-next/test/Async.test.tsx diff --git a/packages/effect-fc-next/src/Component.ts b/packages/effect-fc-next/src/Component.ts index adcafd1..e17caf2 100644 --- a/packages/effect-fc-next/src/Component.ts +++ b/packages/effect-fc-next/src/Component.ts @@ -1,6 +1,6 @@ /** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */ /** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ -import { Context, type Duration, Effect, Equivalence, Exit, Function, identity, Layer, Pipeable, Predicate, Scheduler, Scope, Tracer } from "effect" +import { Context, type Duration, Effect, Equivalence, Exit, Function, identity, Layer, Pipeable, Predicate, References, Scheduler, Scope, Tracer } from "effect" import * as React from "react" import * as ScopeRegistry from "./ScopeRegistry.js" @@ -133,7 +133,7 @@ export interface ComponentOptions { /** * Context tags that should not trigger component remount when their values change. * - * @default [Tracer.ParentSpan] + * @default [Tracer.ParentSpan, References.CurrentStackFrame, Scheduler.Scheduler, Layer.CurrentMemoMap, Scope.Scope] */ readonly nonReactiveTags: readonly Context.Key[] @@ -163,7 +163,13 @@ export interface ComponentOptions { } export const defaultOptions: ComponentOptions = { - nonReactiveTags: [Tracer.ParentSpan, Scheduler.Scheduler, Layer.CurrentMemoMap], + nonReactiveTags: [ + Tracer.ParentSpan, + References.CurrentStackFrame, + Scheduler.Scheduler, + Layer.CurrentMemoMap, + Scope.Scope, + ], finalizerExecutionStrategy: "sequential", finalizerExecutionDebounce: "100 millis", scopeCommitTimeout: "1 minute", diff --git a/packages/effect-fc-next/test/Async.test.tsx b/packages/effect-fc-next/test/Async.test.tsx new file mode 100644 index 0000000..91182d2 --- /dev/null +++ b/packages/effect-fc-next/test/Async.test.tsx @@ -0,0 +1,63 @@ +import { act, fireEvent, render, screen } from "@testing-library/react" +import { Effect, Layer } from "effect" +import * as React from "react" +import { describe, expect, it, vi } from "vitest" +import * as Async from "../src/Async.js" +import * as Component from "../src/Component.js" +import * as Memoized from "../src/Memoized.js" +import * as ReactRuntime from "../src/ReactRuntime.js" + +describe("Async", () => { + it("does not rerun for an unrelated parent state update", async () => { + const load = vi.fn((_id: number) => Effect.never) + const renderPost = vi.fn() + const runtime = ReactRuntime.make(Layer.empty) + const context = await runtime.runtime.context() + + const Post = Component.make("Post")(function*(props: { readonly id: number }) { + renderPost() + const value = yield* Component.useOnChange(() => load(props.id), [props.id]) + return
{value}
+ }).pipe( + Async.async, + Memoized.memoized, + ) + + const Parent = Component.make("Parent")(function*() { + const [text, setText] = React.useState("") + const AsyncPost = yield* Post.use + + return <> + setText(event.currentTarget.value)} + /> + loading} /> + + }).pipe(Component.withContext(runtime.context)) + + let view!: ReturnType + await act(async () => { + view = render( + + + , + ) + }) + + expect(screen.getByText("loading")).toBeTruthy() + expect(load).toHaveBeenCalledTimes(2) + const callsAfterLoad = load.mock.calls.length + const rendersAfterLoad = renderPost.mock.calls.length + + await act(async () => { + fireEvent.change(screen.getByLabelText("text"), { target: { value: "a" } }) + }) + expect(load).toHaveBeenCalledTimes(callsAfterLoad) + expect(renderPost).toHaveBeenCalledTimes(rendersAfterLoad) + + view.unmount() + await runtime.runtime.dispose() + }) +})