From a6a1a3b675ec6c588416cfb717c4beabbe625392 Mon Sep 17 00:00:00 2001 From: Stef Date: Thu, 22 Jan 2026 21:41:34 +0100 Subject: [PATCH 1/4] feat: ref --- playground/app.tsx | 52 +++++++++++++++++++++++++++-------- src/dom.ts | 11 +++++++- src/jsx-runtime/types/html.ts | 4 +++ src/jsx-runtime/types/svg.ts | 5 ++++ 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/playground/app.tsx b/playground/app.tsx index 04c44fc..aea4257 100644 --- a/playground/app.tsx +++ b/playground/app.tsx @@ -1,4 +1,14 @@ -import { Context, Effect, Layer, Stream } from "effect"; +import { + Console, + Context, + Effect, + Layer, + Option, + pipe, + Ref, + Stream, + SubscriptionRef, +} from "effect"; import { mount } from "@/api"; const MyValue = Context.GenericTag<{ value: string }>("MyService"); @@ -10,24 +20,42 @@ const A = (props: { label: string }) => Stream.make("?"), Stream.fromEffect( Effect.gen(function* () { - const value = yield* MyValue; const delay = Math.floor(Math.random() * 2000) + 1000; + const ref = yield* SubscriptionRef.make>( + Option.none(), + ); + + yield* pipe( + ref.changes, + Stream.filter(Option.isSome), + Stream.runForEach((option) => + Option.tap(option, (value) => { + console.log("tap", { value }); + return Option.some(value); + }), + ), + Effect.fork, + ); + yield* Effect.sleep(delay); - return `${props.label}:${value.value}`; + + return {props.label}; }), ), ); -const App = () => ( -
-
- View Recipes → +const App = () => { + return ( +
+ + {Array.from({ length: 10 }, (_, i) => i).map((i) => ( + + ))}
- {Array.from({ length: 10 }, (_, i) => i).map((i) => ( - - ))} -
-); + ); +}; Effect.runPromise( mount(, document.body).pipe(Effect.provide(MyValueLayer)), diff --git a/src/dom.ts b/src/dom.ts index ef5b38a..f61d699 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -1,4 +1,4 @@ -import { Effect, pipe, Scope, Stream } from "effect"; +import { Effect, Option, pipe, Ref, Scope, Stream } from "effect"; import { RenderContext } from "./render-core"; import type { StreamSubscriptionError } from "./types"; import { isStream, normalizeToStream } from "./utilities"; @@ -43,6 +43,15 @@ export function setElementProps( continue; } + if ( + key === "ref" && + typeof value === "object" && + Ref.RefTypeId in value + ) { + yield* Ref.set(value, Option.some(element)); + continue; + } + // AC10-AC13: Special handling for style if (key === "style") { yield* handleStyle(element, value); diff --git a/src/jsx-runtime/types/html.ts b/src/jsx-runtime/types/html.ts index 3fb3c51..9442e92 100644 --- a/src/jsx-runtime/types/html.ts +++ b/src/jsx-runtime/types/html.ts @@ -1,3 +1,4 @@ +import type { Option, Ref } from "effect"; import type { AriaAttributes } from "./aria"; import type { DOMAttributes, @@ -109,7 +110,10 @@ export type HTMLRole = | "widget" | "window"; export interface HTMLAttributes extends AriaAttributes, DOMAttributes { + // effect-ui internals ======================================================== children?: AttributeValue; + ref?: Ref.Ref>; + // ============================================================================ /** * Provides a hint for generating a keyboard shortcut for the current element. This attribute consists of a space-separated list of characters. The browser should use the first one that exists on the computer keyboard layout. diff --git a/src/jsx-runtime/types/svg.ts b/src/jsx-runtime/types/svg.ts index ceee3c6..b6c5af4 100644 --- a/src/jsx-runtime/types/svg.ts +++ b/src/jsx-runtime/types/svg.ts @@ -1,3 +1,4 @@ +import type { Option, Ref } from "effect"; import type { DOMAttributes } from "./dom"; import type { AttributeValue, JSXChild, StyleAttributeValue } from "./values"; @@ -62,7 +63,11 @@ type ImagePreserveAspectRatio = | "defer xMaxYMax slice"; type SVGUnits = "userSpaceOnUse" | "objectBoundingBox"; export interface SVGAttributes extends DOMAttributes { + // effect-ui internals ======================================================== children?: AttributeValue; + ref?: Ref.Ref>; + // ============================================================================ + id?: AttributeValue; lang?: AttributeValue; /** From d3b960ffaadf87d827f4e68757e73bb27325f19a Mon Sep 17 00:00:00 2001 From: Stef Date: Thu, 22 Jan 2026 21:49:10 +0100 Subject: [PATCH 2/4] feat: add ref handling tests and element-ref recipe Add comprehensive test coverage for ref support and create a practical recipe demonstrating element ref usage patterns including auto-focus, dimension measurement, canvas drawing, and scroll-into-view. Co-Authored-By: Claude Opus 4.5 --- playground/recipes/app.tsx | 6 + playground/recipes/element-ref/app.tsx | 275 +++++++++++++++++ .../recipes/element-ref/element-ref.readme.md | 197 +++++++++++++ playground/recipes/element-ref/index.html | 87 ++++++ src/dom.test.tsx | 277 +++++++++++++++++- 5 files changed, 841 insertions(+), 1 deletion(-) create mode 100644 playground/recipes/element-ref/app.tsx create mode 100644 playground/recipes/element-ref/element-ref.readme.md create mode 100644 playground/recipes/element-ref/index.html diff --git a/playground/recipes/app.tsx b/playground/recipes/app.tsx index 6fda304..7045718 100644 --- a/playground/recipes/app.tsx +++ b/playground/recipes/app.tsx @@ -49,6 +49,12 @@ const recipes = [ name: "SubscriptionRef", description: "Reactive state with SubscriptionRef and .changes streams", }, + { + slug: "element-ref", + name: "Element Ref", + description: + "Direct DOM element access via Ref for focus, measurement, and imperative operations", + }, ]; const RecipeCard = ({ diff --git a/playground/recipes/element-ref/app.tsx b/playground/recipes/element-ref/app.tsx new file mode 100644 index 0000000..9466a7e --- /dev/null +++ b/playground/recipes/element-ref/app.tsx @@ -0,0 +1,275 @@ +/** + * Recipe: Element Ref + * + * This recipe demonstrates using Effect's Ref and SubscriptionRef to get + * direct references to DOM elements after they are mounted. + * + * Element refs provide: + * - Direct access to DOM elements for imperative operations + * - Type-safe references (HTMLInputElement, HTMLCanvasElement, etc.) + * - Reactive mount detection via SubscriptionRef.changes + */ + +import { Effect, Option, pipe, Stream, SubscriptionRef } from "effect"; +import { mount } from "@/api"; + +// ============================================================================ +// Example 1: Auto-focus Input on Mount +// ============================================================================ + +/** + * Demonstrates focusing an input element immediately after mount. + */ +const AutoFocusInput = () => + Effect.gen(function* () { + const inputRef = yield* SubscriptionRef.make< + Option.Option + >(Option.none()); + + // Subscribe to ref changes and focus when element is mounted + yield* pipe( + inputRef.changes, + Stream.filter(Option.isSome), + Stream.take(1), + Stream.runForEach((option) => + Effect.sync(() => { + const element = Option.getOrThrow(option); + element.focus(); + }), + ), + Effect.fork, + ); + + return ( +
+

This input is automatically focused on mount:

+ +
+ ); + }); + +// ============================================================================ +// Example 2: Measure Element Dimensions +// ============================================================================ + +/** + * Demonstrates measuring element dimensions after mount. + */ +const MeasureElement = () => + Effect.gen(function* () { + const boxRef = yield* SubscriptionRef.make>( + Option.none(), + ); + const dimensions = yield* SubscriptionRef.make("Measuring..."); + + // Measure dimensions when element is mounted + yield* pipe( + boxRef.changes, + Stream.filter(Option.isSome), + Stream.take(1), + Stream.runForEach((option) => + Effect.gen(function* () { + const element = Option.getOrThrow(option); + const rect = element.getBoundingClientRect(); + yield* SubscriptionRef.set( + dimensions, + `Width: ${rect.width}px, Height: ${rect.height}px`, + ); + }), + ), + Effect.fork, + ); + + return ( +
+
+ Measured Box +
+

+ Dimensions: {dimensions.changes} +

+
+ ); + }); + +// ============================================================================ +// Example 3: Canvas Drawing +// ============================================================================ + +/** + * Demonstrates drawing on a canvas element after mount. + */ +const CanvasDrawing = () => + Effect.gen(function* () { + const canvasRef = yield* SubscriptionRef.make< + Option.Option + >(Option.none()); + + // Draw on canvas when mounted + yield* pipe( + canvasRef.changes, + Stream.filter(Option.isSome), + Stream.take(1), + Stream.runForEach((option) => + Effect.sync(() => { + const canvas = Option.getOrThrow(option); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Draw a simple pattern + ctx.fillStyle = "#000"; + ctx.fillRect(10, 10, 80, 80); + + ctx.fillStyle = "#666"; + ctx.beginPath(); + ctx.arc(150, 50, 40, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = "#999"; + ctx.beginPath(); + ctx.moveTo(250, 10); + ctx.lineTo(290, 90); + ctx.lineTo(210, 90); + ctx.closePath(); + ctx.fill(); + }), + ), + Effect.fork, + ); + + return ( +
+

Shapes drawn on canvas after mount:

+ +
+ ); + }); + +// ============================================================================ +// Example 4: Scroll Into View +// ============================================================================ + +/** + * Demonstrates scrolling an element into view on button click. + */ +const ScrollIntoView = () => + Effect.gen(function* () { + const targetRef = yield* SubscriptionRef.make>( + Option.none(), + ); + + const scrollToTarget = () => + Effect.gen(function* () { + const option = yield* SubscriptionRef.get(targetRef); + if (Option.isSome(option)) { + Option.getOrThrow(option).scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }); + + return ( +
+ +
+
+ Scroll down to find the target... +
+
+ Keep scrolling... +
+
+ Target Element +
+
+ More content below... +
+
+
+ ); + }); + +// ============================================================================ +// App +// ============================================================================ + +const App = () => ( +
+ + ← Back to Recipes + +

Element Ref

+ +
+

1. Auto-focus Input

+

Focus an input element immediately after mount.

+ +
+ +
+

2. Measure Element Dimensions

+

Get element dimensions using getBoundingClientRect().

+ +
+ +
+

3. Canvas Drawing

+

Draw on a canvas element after mount.

+ +
+ +
+

4. Scroll Into View

+

Scroll to an element using scrollIntoView().

+ +
+
+); + +// biome-ignore lint/style/noNonNullAssertion: playground code, element always exists +Effect.runPromise(mount(, document.getElementById("root")!)); diff --git a/playground/recipes/element-ref/element-ref.readme.md b/playground/recipes/element-ref/element-ref.readme.md new file mode 100644 index 0000000..7db1199 --- /dev/null +++ b/playground/recipes/element-ref/element-ref.readme.md @@ -0,0 +1,197 @@ +# Element Ref + +## Overview + +This recipe demonstrates using Effect's `Ref` and `SubscriptionRef` to get direct references to DOM elements after they are mounted. Element refs enable imperative DOM operations like focusing inputs, measuring dimensions, drawing on canvas, or triggering scroll behavior. + +## Problem + +Sometimes you need direct access to a DOM element to perform operations that can't be expressed declaratively: + +- Focusing an input element programmatically +- Measuring element dimensions with `getBoundingClientRect()` +- Drawing on a `` element +- Scrolling an element into view +- Integrating with third-party libraries that require DOM nodes + +React-style refs solve this by providing a mutable container, but Effect provides a more powerful pattern using `Ref` and `SubscriptionRef`. + +## Solution + +Use `SubscriptionRef>` to hold an optional reference to the DOM element: + +```typescript +const AutoFocusInput = () => + Effect.gen(function* () { + const inputRef = yield* SubscriptionRef.make>( + Option.none() + ); + + // Subscribe to ref changes and focus when element is mounted + yield* pipe( + inputRef.changes, + Stream.filter(Option.isSome), + Stream.take(1), + Stream.runForEach((option) => + Effect.sync(() => { + const element = Option.getOrThrow(option); + element.focus(); + }) + ), + Effect.fork + ); + + return ; + }); +``` + +## How It Works + +1. **Create the ref**: `SubscriptionRef.make>(Option.none())` creates a ref initialized to `Option.none()` + +2. **Attach to element**: The `ref` prop accepts a `Ref` or `SubscriptionRef`. When the element is created, the ref is set to `Option.some(element)` + +3. **React to mount**: Using `SubscriptionRef`, you can subscribe to `.changes` and react when the element becomes available + +4. **Type safety**: The element type is preserved - `Ref>` ensures you get an `HTMLInputElement` with all its properties + +5. **Single emission**: The ref is set exactly once during element creation + +## When to Use + +Use element refs when you need to: + +- **Focus management**: Auto-focus inputs, manage focus traps in modals +- **Measurements**: Get element dimensions, positions, or scroll offsets +- **Canvas/WebGL**: Access canvas context for drawing operations +- **Scroll control**: Scroll elements into view, implement virtual scrolling +- **Third-party integrations**: Pass DOM nodes to libraries like D3, Chart.js, or video players +- **Form handling**: Access input values directly, trigger form submissions + +## Usage Patterns + +### Auto-focus on Mount + +```typescript +const AutoFocus = () => + Effect.gen(function* () { + const ref = yield* SubscriptionRef.make>( + Option.none() + ); + + yield* pipe( + ref.changes, + Stream.filter(Option.isSome), + Stream.take(1), + Stream.runForEach((opt) => + Effect.sync(() => Option.getOrThrow(opt).focus()) + ), + Effect.fork + ); + + return ; + }); +``` + +### Measure Dimensions + +```typescript +const Measure = () => + Effect.gen(function* () { + const ref = yield* SubscriptionRef.make>( + Option.none() + ); + const size = yield* SubscriptionRef.make("..."); + + yield* pipe( + ref.changes, + Stream.filter(Option.isSome), + Stream.take(1), + Stream.runForEach((opt) => + Effect.gen(function* () { + const rect = Option.getOrThrow(opt).getBoundingClientRect(); + yield* SubscriptionRef.set(size, `${rect.width}x${rect.height}`); + }) + ), + Effect.fork + ); + + return ( +
+
Box
+

Size: {size.changes}

+
+ ); + }); +``` + +### Imperative Actions via Button + +```typescript +const ScrollToElement = () => + Effect.gen(function* () { + const targetRef = yield* SubscriptionRef.make>( + Option.none() + ); + + const scrollTo = () => + Effect.gen(function* () { + const opt = yield* SubscriptionRef.get(targetRef); + if (Option.isSome(opt)) { + Option.getOrThrow(opt).scrollIntoView({ behavior: "smooth" }); + } + }); + + return ( +
+ +
Target Element
+
+ ); + }); +``` + +### Canvas Drawing + +```typescript +const CanvasExample = () => + Effect.gen(function* () { + const canvasRef = yield* SubscriptionRef.make>( + Option.none() + ); + + yield* pipe( + canvasRef.changes, + Stream.filter(Option.isSome), + Stream.take(1), + Stream.runForEach((opt) => + Effect.sync(() => { + const ctx = Option.getOrThrow(opt).getContext("2d"); + if (ctx) { + ctx.fillStyle = "#000"; + ctx.fillRect(10, 10, 50, 50); + } + }) + ), + Effect.fork + ); + + return ; + }); +``` + +## Comparison with React Refs + +| React | Effect UI | +|-------|-----------| +| `useRef(null)` | `SubscriptionRef.make>(Option.none())` | +| `ref.current` (nullable) | `Ref.get(ref)` returns `Option` | +| `useEffect(() => { if (ref.current) ... })` | `Stream.filter(Option.isSome)` on `.changes` | +| Manual null checks | Type-safe `Option` operations | + +## Notes + +- Refs are set once during element creation and are not cleared on unmount +- Use `SubscriptionRef` when you need to react to the element becoming available +- Use regular `Ref` when you only need to read the element imperatively later +- The `Option` wrapper correctly handles the "not yet mounted" state diff --git a/playground/recipes/element-ref/index.html b/playground/recipes/element-ref/index.html new file mode 100644 index 0000000..0b23444 --- /dev/null +++ b/playground/recipes/element-ref/index.html @@ -0,0 +1,87 @@ + + + + + + Recipe: Element Ref - Effect UI + + + +
+ + + diff --git a/src/dom.test.tsx b/src/dom.test.tsx index c831c33..72485fb 100644 --- a/src/dom.test.tsx +++ b/src/dom.test.tsx @@ -1,6 +1,6 @@ import * as assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { Effect, Schedule, Stream } from "effect"; +import { Effect, Option, Ref, Schedule, Stream, SubscriptionRef } from "effect"; import { JSDOM } from "jsdom"; import { mount } from "./api"; @@ -1515,3 +1515,278 @@ describe("Stream-Based Fallback Pattern", () => { assert.ok(!root.querySelector(".child-loading")); }); }); + +// ============================================================================ +// Ref Handling +// ============================================================================ + +describe("Ref Handling", () => { + it("should set ref to Option.some(element) during element creation", async () => { + createTestDOM(); + const root = createRoot(); + + const ref = await Effect.runPromise( + Ref.make>(Option.none()), + ); + + await runMount(
test
, root); + + const refValue = await Effect.runPromise(Ref.get(ref)); + assert.ok(Option.isSome(refValue), "Ref should contain Option.some"); + const element = Option.getOrThrow(refValue); + assert.equal(element.tagName, "DIV"); + assert.equal(element.textContent, "test"); + }); + + it("should work with SubscriptionRef", async () => { + createTestDOM(); + const root = createRoot(); + + const ref = await Effect.runPromise( + SubscriptionRef.make>(Option.none()), + ); + + await runMount( + + content + , + root, + ); + + const refValue = await Effect.runPromise(Ref.get(ref)); + assert.ok( + Option.isSome(refValue), + "SubscriptionRef should contain Option.some", + ); + const element = Option.getOrThrow(refValue); + assert.equal(element.tagName, "SPAN"); + assert.equal(element.className, "test-span"); + }); + + it("should emit on SubscriptionRef.changes after mount", async () => { + createTestDOM(); + const root = createRoot(); + + // Create the ref and track if we received an element via the stream + let receivedElement: HTMLInputElement | null = null; + + await Effect.runPromise( + Effect.gen(function* () { + const ref = yield* SubscriptionRef.make< + Option.Option + >(Option.none()); + + // Subscribe to changes and capture the first Option.some emission + yield* Effect.fork( + Stream.runForEach(Stream.filter(ref.changes, Option.isSome), (opt) => + Effect.sync(() => { + if (receivedElement === null) { + receivedElement = Option.getOrThrow(opt); + } + }), + ), + ); + + // Give the subscription time to start + yield* Effect.sleep("50 millis"); + + // Mount will set the ref + return ; + }).pipe( + Effect.flatMap((jsx) => + Effect.promise(async () => { + await runMount(jsx, root); + }), + ), + ), + ); + + // Wait for emission to be processed + await waitForStream(); + + // Verify we received the element through the subscription + assert.ok( + receivedElement !== null, + "Should have received element via stream", + ); + assert.equal(receivedElement?.tagName, "INPUT"); + assert.equal(receivedElement?.type, "text"); + }); + + it("should work with HTMLElement for div", async () => { + createTestDOM(); + const root = createRoot(); + + const ref = await Effect.runPromise( + Ref.make>(Option.none()), + ); + + await runMount(
, root); + + const element = Option.getOrThrow(await Effect.runPromise(Ref.get(ref))); + assert.equal(element.tagName, "DIV"); + assert.equal(element.id, "my-div"); + }); + + it("should work with HTMLInputElement", async () => { + createTestDOM(); + const root = createRoot(); + + const ref = await Effect.runPromise( + Ref.make>(Option.none()), + ); + + await runMount( + , + root, + ); + + const element = Option.getOrThrow(await Effect.runPromise(Ref.get(ref))); + assert.equal(element.tagName, "INPUT"); + assert.equal(element.type, "email"); + // Input value is a property, should be set + assert.equal(element.value, "test@example.com"); + }); + + it("should work with HTMLButtonElement", async () => { + createTestDOM(); + const root = createRoot(); + + const ref = await Effect.runPromise( + Ref.make>(Option.none()), + ); + + await runMount( + , + root, + ); + + const element = Option.getOrThrow(await Effect.runPromise(Ref.get(ref))); + assert.equal(element.tagName, "BUTTON"); + assert.equal(element.type, "submit"); + assert.equal(element.disabled, true); + }); + + it("should process other props normally alongside ref", async () => { + createTestDOM(); + const root = createRoot(); + + const ref = await Effect.runPromise( + Ref.make>(Option.none()), + ); + + await runMount( +
+ content +
, + root, + ); + + const element = Option.getOrThrow(await Effect.runPromise(Ref.get(ref))); + assert.equal(element.id, "test-id"); + assert.equal(element.className, "test-class"); + assert.equal(element.getAttribute("data-custom"), "custom-value"); + assert.equal(element.style.color, "red"); + assert.equal(element.textContent, "content"); + }); + + it("should not treat non-Ref objects as refs", async () => { + createTestDOM(); + const root = createRoot(); + + // An object that looks similar but is not a Ref + const notARef = { current: null }; + + await runMount( + // @ts-expect-error - testing invalid ref type +
+ test +
, + root, + ); + + const div = root.children[0] as HTMLElement; + // The notARef object should have been ignored or treated as attribute + assert.equal(div.getAttribute("data-test"), "value"); + assert.equal(div.textContent, "test"); + }); + + it("should handle multiple refs on different elements independently", async () => { + createTestDOM(); + const root = createRoot(); + + const divRef = await Effect.runPromise( + Ref.make>(Option.none()), + ); + const spanRef = await Effect.runPromise( + Ref.make>(Option.none()), + ); + const inputRef = await Effect.runPromise( + Ref.make>(Option.none()), + ); + + await runMount( +
+ text + +
, + root, + ); + + const divElement = Option.getOrThrow( + await Effect.runPromise(Ref.get(divRef)), + ); + const spanElement = Option.getOrThrow( + await Effect.runPromise(Ref.get(spanRef)), + ); + const inputElement = Option.getOrThrow( + await Effect.runPromise(Ref.get(inputRef)), + ); + + assert.equal(divElement.tagName, "DIV"); + assert.equal(spanElement.tagName, "SPAN"); + assert.equal(inputElement.tagName, "INPUT"); + + // Verify they're all different elements + assert.notEqual(divElement, spanElement); + assert.notEqual(spanElement, inputElement); + assert.notEqual(divElement, inputElement); + + // Verify parent-child relationships + assert.equal(spanElement.parentElement, divElement); + assert.equal(inputElement.parentElement, divElement); + }); + + it("should have Option.none as initial value before mount", async () => { + createTestDOM(); + const root = createRoot(); + + const ref = await Effect.runPromise( + Ref.make>(Option.none()), + ); + + // Check value before mount + const valueBefore = await Effect.runPromise(Ref.get(ref)); + assert.ok( + Option.isNone(valueBefore), + "Ref should be Option.none before mount", + ); + + await runMount(
test
, root); + + // Check value after mount + const valueAfter = await Effect.runPromise(Ref.get(ref)); + assert.ok( + Option.isSome(valueAfter), + "Ref should be Option.some after mount", + ); + }); +}); From 99892d2fc88aed63fbd0fecba8d3fd87d953280f Mon Sep 17 00:00:00 2001 From: Stef Date: Thu, 22 Jan 2026 21:52:44 +0100 Subject: [PATCH 3/4] fix: resolve TypeScript narrowing issue in ref test Use object wrapper for captured element to avoid TypeScript control flow analysis issues with closure mutations. Co-Authored-By: Claude Opus 4.5 --- src/dom.test.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/dom.test.tsx b/src/dom.test.tsx index 72485fb..6b0dc08 100644 --- a/src/dom.test.tsx +++ b/src/dom.test.tsx @@ -1568,7 +1568,7 @@ describe("Ref Handling", () => { const root = createRoot(); // Create the ref and track if we received an element via the stream - let receivedElement: HTMLInputElement | null = null; + const captured: { element: HTMLInputElement | null } = { element: null }; await Effect.runPromise( Effect.gen(function* () { @@ -1580,8 +1580,8 @@ describe("Ref Handling", () => { yield* Effect.fork( Stream.runForEach(Stream.filter(ref.changes, Option.isSome), (opt) => Effect.sync(() => { - if (receivedElement === null) { - receivedElement = Option.getOrThrow(opt); + if (captured.element === null) { + captured.element = Option.getOrThrow(opt); } }), ), @@ -1605,12 +1605,13 @@ describe("Ref Handling", () => { await waitForStream(); // Verify we received the element through the subscription + const receivedElement = captured.element; assert.ok( receivedElement !== null, "Should have received element via stream", ); - assert.equal(receivedElement?.tagName, "INPUT"); - assert.equal(receivedElement?.type, "text"); + assert.equal(receivedElement.tagName, "INPUT"); + assert.equal(receivedElement.type, "text"); }); it("should work with HTMLElement for div", async () => { From 013c8837ecb00591a8d1e0f4f0b2bb1a3c4c7818 Mon Sep 17 00:00:00 2001 From: Stef Date: Thu, 22 Jan 2026 21:53:22 +0100 Subject: [PATCH 4/4] chore: remove unused imports from playground Co-Authored-By: Claude Opus 4.5 --- playground/app.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/playground/app.tsx b/playground/app.tsx index aea4257..d004543 100644 --- a/playground/app.tsx +++ b/playground/app.tsx @@ -1,11 +1,9 @@ import { - Console, Context, Effect, Layer, Option, pipe, - Ref, Stream, SubscriptionRef, } from "effect";