Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/subscribe-surface-errors.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 3 additions & 13 deletions packages/dom/src/Control/ClientControlCtx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
* 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";

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;

Expand Down Expand Up @@ -154,17 +154,7 @@ const createClientControlCtx = (): IControlCtx<DOMElement> => {
containerElement.insertBefore(entry.element, refChild);
}),

subscribe: <V, E, R>(
readable: Readable.Readable<V>,
handler: (value: V) => Effect.Effect<void, E, R>,
): Effect.Effect<void, E, R> =>
Effect.gen(function* () {
const scope = yield* Effect.scope;
yield* readable.changes.pipe(
Stream.runForEach(handler),
Effect.forkIn(scope),
);
}) as Effect.Effect<void, E, R>,
subscribe: subscribeReconcile,
};

return ctx;
Expand Down
28 changes: 4 additions & 24 deletions packages/dom/src/Control/HydrationControlCtx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
* 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";

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;

Expand Down Expand Up @@ -207,17 +207,7 @@ const createClientLikeControlCtx = (
containerElement.insertBefore(entry.element, refChild);
}),

subscribe: <V, E, R>(
readable: Readable.Readable<V>,
handler: (value: V) => Effect.Effect<void, E, R>,
): Effect.Effect<void, E, R> =>
Effect.gen(function* () {
const scope = yield* Effect.scope;
yield* readable.changes.pipe(
Stream.runForEach(handler),
Effect.forkIn(scope),
);
}) as Effect.Effect<void, E, R>,
subscribe: subscribeReconcile,
};

return ctx;
Expand Down Expand Up @@ -393,17 +383,7 @@ const createHydrationControlCtx = (
containerElement.insertBefore(entry.element, refChild);
}),

subscribe: <V, E, R>(
readable: Readable.Readable<V>,
handler: (value: V) => Effect.Effect<void, E, R>,
): Effect.Effect<void, E, R> =>
Effect.gen(function* () {
const scope = yield* Effect.scope;
yield* readable.changes.pipe(
Stream.runForEach(handler),
Effect.forkIn(scope),
);
}) as Effect.Effect<void, E, R>,
subscribe: subscribeReconcile,
};

return ctx;
Expand Down
82 changes: 82 additions & 0 deletions packages/dom/src/Control/subscribeReconcile.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

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();
});
});
61 changes: 61 additions & 0 deletions packages/dom/src/Control/subscribeReconcile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* 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 {@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
* and back can recover; without this, one error would freeze all
* future updates too.
*/

import { Cause, Console, Effect, Stream } from "effect";

import type { Readable } from "@effex/core";

export const subscribeReconcile = <V, E, R>(
readable: Readable.Readable<V>,
handler: (value: V) => Effect.Effect<void, E, R>,
): Effect.Effect<void, E, R> =>
Effect.gen(function* () {
const scope = yield* Effect.scope;
yield* readable.changes.pipe(
Stream.runForEach((value) =>
handler(value).pipe(
Effect.tapErrorCause((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.
Effect.catchAllCause(() => Effect.void),
),
),
Effect.forkIn(scope),
);
}) as Effect.Effect<void, E, R>;
Loading