From 4f1f4fe7187f3f45efd7e4e8519a0f611a456f39 Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Fri, 24 Jul 2026 11:38:33 -0400 Subject: [PATCH 1/2] fix: surface reconcile subscription failures via console.error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientControlCtx and both HydrationControlCtx factories implemented subscribe(readable, handler) by forking the stream reader into the parent scope via Effect.forkIn. That's the right lifecycle model but forkIn swallows fiber failures — if handler ever failed (a route render threw, a data provider died mid-navigation, a slot insertion errored) the fiber died silently and the UI froze with no console output. Extracted a shared subscribeReconcile helper that logs each failing handler run with a full Effect Cause.pretty (fiber trace + underlying errors) and catches the failure at the per-value boundary so a single bad update doesn't kill the subscription — subsequent state changes still fire the handler. Navigation to a broken route and back now recovers. Reconcile subscription is what drives Outlet's re-render on nav.pathname changes, so this specifically fixes silent navigation failures reported downstream. Co-Authored-By: Claude Opus 4.7 --- .changeset/subscribe-surface-errors.md | 12 +++ packages/dom/src/Control/ClientControlCtx.ts | 16 +--- .../dom/src/Control/HydrationControlCtx.ts | 28 +------ .../src/Control/subscribeReconcile.test.ts | 82 +++++++++++++++++++ .../dom/src/Control/subscribeReconcile.ts | 63 ++++++++++++++ 5 files changed, 164 insertions(+), 37 deletions(-) create mode 100644 .changeset/subscribe-surface-errors.md create mode 100644 packages/dom/src/Control/subscribeReconcile.test.ts create mode 100644 packages/dom/src/Control/subscribeReconcile.ts diff --git a/.changeset/subscribe-surface-errors.md b/.changeset/subscribe-surface-errors.md new file mode 100644 index 0000000..43be6c7 --- /dev/null +++ b/.changeset/subscribe-surface-errors.md @@ -0,0 +1,12 @@ +--- +"@effex/dom": patch +--- + +Surface reconcile-subscription failures via `console.error`. Every `ControlCtx` (`ClientControlCtx`, both `HydrationControlCtx` factories) implemented `subscribe(readable, handler)` by forking the stream reader into the parent scope. That's the right lifecycle model, but `Effect.forkIn` swallows fiber failures — if the handler ever threw (a route render errored, a data provider died mid-nav, a slot insertion blew up), the fiber died silently and the UI froze with no console output. + +Now every subscription goes through a shared `subscribeReconcile` helper that: + +1. Logs each failing handler run to `console.error` with a full Effect `Cause.pretty` (fiber trace + underlying errors) so devtools and error trackers pick it up. +2. Catches the failure at the per-value boundary so a single bad update doesn't kill the subscription — subsequent state changes still fire the handler. Navigating away from a broken route and back now recovers. + +Practical impact: `Outlet` navigation errors (route render throws, data-provider rejects, guard errors) now surface immediately at the source instead of appearing as an unresponsive Outlet. diff --git a/packages/dom/src/Control/ClientControlCtx.ts b/packages/dom/src/Control/ClientControlCtx.ts index fcb394a..2b1e397 100644 --- a/packages/dom/src/Control/ClientControlCtx.ts +++ b/packages/dom/src/Control/ClientControlCtx.ts @@ -3,14 +3,13 @@ * Full reactive updates with optional animation support. */ -import { Effect, Layer, Scope, Stream } from "effect"; +import { Effect, Layer, Scope } from "effect"; import { ControlCtx, RendererContext, Signal, type IControlCtx, - type Readable, type Renderer, type SlotEntry, } from "@effex/core"; @@ -18,6 +17,7 @@ import { import * as Element from "../Element/index.js"; import { DOMRenderer } from "../Render/DOMRenderer.js"; import { forkSlotEnter, forkSlotRemoval } from "./slotAnimation.js"; +import { subscribeReconcile } from "./subscribeReconcile.js"; type DOMElement = HTMLElement | SVGElement; @@ -154,17 +154,7 @@ const createClientControlCtx = (): IControlCtx => { containerElement.insertBefore(entry.element, refChild); }), - subscribe: ( - readable: Readable.Readable, - handler: (value: V) => Effect.Effect, - ): Effect.Effect => - Effect.gen(function* () { - const scope = yield* Effect.scope; - yield* readable.changes.pipe( - Stream.runForEach(handler), - Effect.forkIn(scope), - ); - }) as Effect.Effect, + subscribe: subscribeReconcile, }; return ctx; diff --git a/packages/dom/src/Control/HydrationControlCtx.ts b/packages/dom/src/Control/HydrationControlCtx.ts index c9e841f..b5c5e3e 100644 --- a/packages/dom/src/Control/HydrationControlCtx.ts +++ b/packages/dom/src/Control/HydrationControlCtx.ts @@ -3,14 +3,13 @@ * Finds existing DOM and attaches handlers, then subscribes like client mode. */ -import { Context, Effect, Layer, Scope, Stream } from "effect"; +import { Context, Effect, Layer, Scope } from "effect"; import { ControlCtx, RendererContext, Signal, type IControlCtx, - type Readable, type Renderer, type SlotEntry, } from "@effex/core"; @@ -18,6 +17,7 @@ import { import * as Element from "../Element/index.js"; import { DOMRenderer } from "../Render/DOMRenderer.js"; import { forkSlotEnter, forkSlotRemoval } from "./slotAnimation.js"; +import { subscribeReconcile } from "./subscribeReconcile.js"; type DOMElement = HTMLElement | SVGElement; @@ -207,17 +207,7 @@ const createClientLikeControlCtx = ( containerElement.insertBefore(entry.element, refChild); }), - subscribe: ( - readable: Readable.Readable, - handler: (value: V) => Effect.Effect, - ): Effect.Effect => - Effect.gen(function* () { - const scope = yield* Effect.scope; - yield* readable.changes.pipe( - Stream.runForEach(handler), - Effect.forkIn(scope), - ); - }) as Effect.Effect, + subscribe: subscribeReconcile, }; return ctx; @@ -393,17 +383,7 @@ const createHydrationControlCtx = ( containerElement.insertBefore(entry.element, refChild); }), - subscribe: ( - readable: Readable.Readable, - handler: (value: V) => Effect.Effect, - ): Effect.Effect => - Effect.gen(function* () { - const scope = yield* Effect.scope; - yield* readable.changes.pipe( - Stream.runForEach(handler), - Effect.forkIn(scope), - ); - }) as Effect.Effect, + subscribe: subscribeReconcile, }; return ctx; diff --git a/packages/dom/src/Control/subscribeReconcile.test.ts b/packages/dom/src/Control/subscribeReconcile.test.ts new file mode 100644 index 0000000..79eae16 --- /dev/null +++ b/packages/dom/src/Control/subscribeReconcile.test.ts @@ -0,0 +1,82 @@ +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Signal } from "@effex/core"; + +import { subscribeReconcile } from "./subscribeReconcile.js"; + +describe("subscribeReconcile", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + it("logs a failing handler's cause to console.error", async () => { + const boom = new Error("route render exploded"); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const sig = yield* Signal.make("initial"); + yield* subscribeReconcile(sig, () => Effect.fail(boom)); + yield* Effect.sleep("5 millis"); + yield* sig.set("next"); // triggers the failing handler + yield* Effect.sleep("20 millis"); + }), + ), + ); + + expect(errorSpy).toHaveBeenCalled(); + const message = errorSpy.mock.calls[0]?.[0] as string | undefined; + expect(message).toMatch(/\[@effex\/dom\] Reconcile handler failed/); + }); + + it("keeps processing subsequent updates after a handler failure", async () => { + // The subscription must survive one failure; otherwise a single broken + // route would freeze all future navigations. + const observed: string[] = []; + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const sig = yield* Signal.make("initial"); + yield* subscribeReconcile(sig, (value) => { + if (value === "bad") return Effect.fail(new Error("nope")); + return Effect.sync(() => { + observed.push(value); + }); + }); + yield* Effect.sleep("5 millis"); + yield* sig.set("first"); + yield* Effect.sleep("5 millis"); + yield* sig.set("bad"); // fails + yield* Effect.sleep("5 millis"); + yield* sig.set("second"); // must still be observed + yield* Effect.sleep("20 millis"); + }), + ), + ); + + expect(observed).toEqual(["first", "second"]); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it("is silent when the handler succeeds", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const sig = yield* Signal.make(0); + yield* subscribeReconcile(sig, () => Effect.void); + yield* Effect.sleep("5 millis"); + yield* sig.set(1); + yield* Effect.sleep("10 millis"); + }), + ), + ); + + expect(errorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dom/src/Control/subscribeReconcile.ts b/packages/dom/src/Control/subscribeReconcile.ts new file mode 100644 index 0000000..068c5f9 --- /dev/null +++ b/packages/dom/src/Control/subscribeReconcile.ts @@ -0,0 +1,63 @@ +/** + * Shared subscription helper for ControlCtx implementations. + * + * `ctx.subscribe(readable, handler)` is how `reconcile` keeps a container in + * sync with a reactive value — for `Outlet`, that means "when pathname + * changes, re-run the sync loop that swaps slots." The pattern is: + * + * ``` + * const scope = yield* Effect.scope; + * yield* readable.changes.pipe( + * Stream.runForEach(handler), + * Effect.forkIn(scope), + * ); + * ``` + * + * That forks the stream reader into the parent scope. The problem: if the + * handler ever fails — a route render throws, a data-provider dies, a slot + * insertion errors — the forked fiber dies with a defect and the failure + * is invisible. The user sees a frozen UI (blank Outlet, stale content) with + * nothing in the console pointing at the cause. + * + * This helper wraps the pattern with two behaviours: + * 1. Every failed handler run is logged via `console.error` with a full + * Effect Cause (fiber trace + inner errors) so navigation/reconcile + * failures surface. + * 2. The failure is swallowed at the per-value boundary so a single bad + * update doesn't kill the subscription — subsequent state changes + * still trigger the handler. A user who navigates to a broken route + * and back can recover; without this, one error would freeze all + * future updates too. + */ + +import { Cause, Effect, Stream } from "effect"; + +import type { Readable } from "@effex/core"; + +export const subscribeReconcile = ( + readable: Readable.Readable, + handler: (value: V) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const scope = yield* Effect.scope; + yield* readable.changes.pipe( + Stream.runForEach((value) => + handler(value).pipe( + Effect.tapErrorCause((cause) => + Effect.sync(() => { + // eslint-disable-next-line no-console + console.error( + `[@effex/dom] Reconcile handler failed for value:`, + value, + `\nCause:\n${Cause.pretty(cause)}`, + ); + }), + ), + // Swallow so the subscription survives; the error is already + // logged, and subsequent updates should still be applied. + Effect.catchAllCause(() => Effect.void), + ), + ), + Effect.forkIn(scope), + ); + }) as Effect.Effect; From caff662647cfc4fa5acc3c7b39ec481c7e4d953f Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Fri, 24 Jul 2026 13:00:10 -0400 Subject: [PATCH 2/2] chore: use Effect Console instead of raw console.error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effect's Console service is idiomatic in an Effect-native codebase — it runs inside the fiber, returns Effect (no need to wrap in Effect.sync), and can be swapped via Layer for tests. Default sink delegates to the real console, so existing spy-on-console tests keep working. Co-Authored-By: Claude Opus 4.7 --- .../dom/src/Control/subscribeReconcile.ts | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/dom/src/Control/subscribeReconcile.ts b/packages/dom/src/Control/subscribeReconcile.ts index 068c5f9..5712470 100644 --- a/packages/dom/src/Control/subscribeReconcile.ts +++ b/packages/dom/src/Control/subscribeReconcile.ts @@ -20,9 +20,10 @@ * nothing in the console pointing at the cause. * * This helper wraps the pattern with two behaviours: - * 1. Every failed handler run is logged via `console.error` with a full - * Effect Cause (fiber trace + inner errors) so navigation/reconcile - * failures surface. + * 1. Every failed handler run is logged via {@link Console.error} with a + * full Effect Cause (fiber trace + inner errors) so navigation/reconcile + * failures surface. Uses Effect's Console service (not plain + * `console.error`) so tests can swap the sink via Layer. * 2. The failure is swallowed at the per-value boundary so a single bad * update doesn't kill the subscription — subsequent state changes * still trigger the handler. A user who navigates to a broken route @@ -30,7 +31,7 @@ * future updates too. */ -import { Cause, Effect, Stream } from "effect"; +import { Cause, Console, Effect, Stream } from "effect"; import type { Readable } from "@effex/core"; @@ -44,14 +45,11 @@ export const subscribeReconcile = ( Stream.runForEach((value) => handler(value).pipe( Effect.tapErrorCause((cause) => - Effect.sync(() => { - // eslint-disable-next-line no-console - console.error( - `[@effex/dom] Reconcile handler failed for value:`, - value, - `\nCause:\n${Cause.pretty(cause)}`, - ); - }), + Console.error( + `[@effex/dom] Reconcile handler failed for value:`, + value, + `\nCause:\n${Cause.pretty(cause)}`, + ), ), // Swallow so the subscription survives; the error is already // logged, and subsequent updates should still be applied.