Debugging

Stax emits structured debug logs at key framework boundaries — navigation, route resolution, data fetches, animation lifecycle, and reconcile handler invocations. Enable them by lowering Effect’s log level to Debug.

Turn on framework logs

Wherever you run your app, wrap the top-level Effect with Logger.withMinimumLogLevel(LogLevel.Debug). Add a Logger layer if you want pretty output.

import { Effect, Logger, LogLevel } from "effect";

Effect.runFork(
  program.pipe(
    Logger.withMinimumLogLevel(LogLevel.Debug),
    Effect.provide(Logger.pretty),
  ),
);

Every framework log carries a subsystem annotation so you can filter by area.

Subsystems

Annotation What it covers
stax.nav pushPath, replacePath, popstate — with from and to paths
stax.outlet Route resolution, guard evaluations, redirects
stax.route-data Which fetch branch was taken (provider vs SPA fallback vs static config), redirect signals from the provider
stax.animation Enter/exit lifecycle begin/end, whether the animation was skipped, how transitionend resolved (transition / animation / timeout / skip)
stax.reconcile Every reconcile sync pass with the triggering value; error-level entries for failed reconcile handlers
stax.readable Emitted by Readable.debug(id) — one initial-value line plus a line per subsequent change

Filter to one subsystem

Use a custom Logger that only prints messages for the subsystem you’re chasing:

import { HashMap, Logger } from "effect";

const onlyNavLogger = Logger.make((opts) => {
  const subsystem = HashMap.get(opts.annotations, "subsystem");
  if (subsystem._tag !== "Some" || subsystem.value !== "stax.nav") return;
  console.log(`[${opts.logLevel.label}]`, opts.message);
});

Effect.runFork(
  program.pipe(
    Logger.withMinimumLogLevel(LogLevel.Debug),
    Effect.provide(Logger.replace(Logger.defaultLogger, onlyNavLogger)),
  ),
);

Cost when disabled

Zero rendering work — messages are only built into strings by the formatter, and when the level is above Debug, the formatter never runs. Message arguments (the objects passed to Effect.logDebug) are constructed regardless — that’s why the framework only logs at low-volume framework boundaries. High-volume paths like Signal.set do NOT emit debug logs; those will get structured inspector hooks in a future release.

Observe a specific Readable

Readable.debug(id) wraps any Readable-producing Effect so its initial value and every subsequent change are logged under stax.readable. Useful when you have a running app and just want to answer “what value does this Signal hold right now, and when does it change?”

import { Signal, Readable } from "@stax-ui/core";

const cart = yield* Signal.make({ items: 0, total: 0 }).pipe(
  Readable.debug("cart"),
);

Pass-through — the returned Readable is unchanged, so debug can be added and removed without any code refactoring. The internal subscription is forked into the enclosing scope and cleaned up automatically.

Adding your own debug logs

logDebug and logError are exported from @stax-ui/core for your own instrumentation. The subsystem argument is typed as `stax.${string}` so custom framework extensions can slot into the same filter mechanism.

import { logDebug, logError } from "@stax-ui/core";

yield* logDebug("cache miss", "stax.my-extension", { key });
yield* logError("cache failure", "stax.my-extension", { cause });

logDebug is filtered by the runtime log level (opt-in visibility). logError always emits so users see failures without needing to configure anything.

App-level logs don’t need the stax. prefix — use Effect.logDebug / Effect.logError and Effect.annotateLogs directly with whatever subsystem convention you like.