Skip to content

Diagnostics

Stewie emits diagnostics with stable STW### codes to catch common mistakes early. Compiler diagnostics surface at build time (and in your editor via the Vite plugin); a few dev-runtime warnings catch things that only show up when code runs. Each message links back to this page.

The compiler is optional — projects using plain JSX without @stewie-js/vite still get the dev-runtime warnings, but not the build-time ones. Where a rule can run in both places it does.

TIP

These are guidance, not gates. Most are warnings. They point at a footgun and suggest the fix; the entry here explains why.

Module-scope reactivity

STW001 — signal() at module scope

Creating a signal() (or computed/store/effect) at module scope makes it an accidental singleton shared across every SSR request. Move the call inside a component body or reactiveScope().

STW002 — computed() at module scope

Same as STW001: a module-scope computed() is shared across requests. Create it inside a component or reactiveScope().

STW003 — store() at module scope

A module-scope store() leaks state across SSR requests. Create it inside a component or reactiveScope().

STW004 — effect() at module scope

Effects must be owned by a component or reactiveScope() so they can be disposed. A module-scope effect is never cleaned up.

STW005 — useAction() outside a component or reactiveScope()

useAction() creates per-call-site pending/error signals that need an owning scope. Call it inside a component body or reactiveScope(). (defineAction() at module scope is fine — it creates no signals.)

STW006 — useResource() outside a component or reactiveScope()

useResource() creates per-call-site data/loading/error signals that need an owning scope. Call it inside a component or reactiveScope(). (defineResource() at module scope is fine.)

STW007 — useTitle() / useMeta() outside a component or reactiveScope()

These create reactive effects that write document.head and must be disposed on unmount. Call them inside a component or reactiveScope().

Signals in JSX

STW010 — Signal referenced but not called in a JSX child

{count} renders the function itself, not its value. Call it: {count()}, or wrap as a function child {() => count()} for a reactive slot.

STW011 — Signal referenced but not called in a JSX attribute

attr={sig} sets the attribute to the function. Call it: attr={sig()} (static read) or attr={() => sig()} (reactive).

STW014 — peek() inside a reactive context

peek() reads without subscribing, so an effect()/computed() that only peek()s a signal won't re-run when it changes. Call the signal directly (sig()) for reactivity, or move the peek() outside the reactive body if the non-tracking read is intentional.

Control flow

STW020 — <Show when> given an eager signal read

when={isOpen()} reads the signal once at mount, so the condition never re-evaluates. Pass the signal directly (when={isOpen}) or wrap it (when={() => isOpen()}). Only genuine Signal/Computed reads are flagged — a static helper call isn't.

STW021 — <For each> given an eager signal read

each={tasks()} reads the signal once at mount, so the list never reacts to changes. Pass the signal directly (each={tasks}) or wrap it (each={() => tasks()}).

STW022 — <For by> returns a non-unique key

A by key function returning a constant or the identity of its parameter breaks keyed reconciliation — rows collapse or re-render incorrectly. Return a per-item unique id: by={(item) => item.id}.

Reactive scope and lifecycle

STW040 — signal() created inside an effect() body

The signal is re-created on every effect run, so its state resets each time and nothing outside the effect can read it. Hoist the signal() call above the effect().

STW041 — onCleanup() called outside a reactive scope

With no owning scope, the cleanup callback is dropped and will never run. Call onCleanup() inside a component body or reactiveScope() so it's tied to a lifecycle. Dev-runtime warning.

STW042 — effect() created inside a computed() body

Computeds must be pure. An effect created here is never cleaned up and can loop when the computed re-evaluates. Move the effect to a component body or reactiveScope().

STW043 — Writing to a signal inside a computed() body

computed(() => { count.set(1); return ... }) mutates state during a pure computation — it can loop or produce surprising results. Move the write to an event handler, effect(), or an action. Type-aware (only genuine signal writes are flagged, not e.g. map.set()).

Context

STW050 — consume() with no ancestor provide()

The context has no provider above the consumer and no default value, so there's nothing to read. Wrap the consumer in a provider, or give the context a default: createContext(defaultValue). Dev-runtime (throws).

STW052 — createContext() called outside module scope

Each createContext() call creates a new context identity, so provide()/consume() pairs across renders won't match. Move createContext() to module top level.

Resource

STW063 — defineResource() without a stable id used with SSR replay

Without an explicit { id }, an auto-counter id is assigned that is not stable across the separate SSR and client builds. To stay safe, an unkeyed resource does not participate in the DataRegistry at all — its SSR-resolved data is refetched on the client and it won't dedupe across components. Pass an explicit id for SSR replay: defineResource(fn, { id: 'fetchUser' }).

Router

STW073 — <Link to> is an external URL

<Link> is for internal client-side navigation. An http(s):// target should be a plain <a href rel="noopener noreferrer">.

STW076 — useParams() read a key the matched route does not declare

The generic on useParams<{ slug: string }>() is a phantom-type carrier — it is never checked against the route's path. Reading a key the matched route does not declare returns undefined under a type that says it is present, and that undefined then flows into app code as if it were a string.

Prefer the value form, where the param names come from the path literal and a wrong key is a compile error:

tsx
const ProductRoute = createRoute('/products/:productId', { component: ProductPage })

const { productId } = useParams(ProductRoute)   // inferred from the path

Warns on property reads only — 'key' in params, Object.keys(params), and spreading stay silent. Dev-only.

SSR / hydration

STW083 — window / document accessed at module scope

The module throws on import in SSR / non-browser environments. Move the access inside a component or effect(), or guard with typeof window !== 'undefined'.

STW100 — mount() called on the server

mount() drives real DOM and is client-only. In a non-browser environment (no document) it fails. Use renderToString() / renderToStream() from @stewie-js/server for SSR. Dev-runtime warning.

Two-way binding ($prop)

STW090 — $prop target is not a signal

$value={x} compiles to x.set(...), so the target must be a writable signal(). A plain value has no .set() and fails at runtime. Pass a signal, or use a one-way binding (value={x}) for a static value.

STW091 — $prop target is read-only

A computed() or a plain () => T accessor is callable but has no .set(), so it can't be a two-way binding target. Use a writable signal() for $prop, or read it one-way (value={x()}).

STW092 — Both $prop and prop specified

$value already implies value. Having both is contradictory — remove the plain value attribute.

STW093 — $prop is not a recognized two-way binding

Only $value (input/textarea/select) and $checked (checkbox/radio) are recognized — they map to input/change events. Any other $prop has no event to write back through, so the binding is silently dead. For custom two-way flow, read the signal and write it back in an explicit event handler.

STW094 — $prop on a readonly element

A two-way binding on a readonly element can't write back, so it's downgraded to one-way. Drop readonly, or use a plain one-way value={...}.

STW095 — $prop on a disabled element

Same as STW094 for disabled elements — the binding is downgraded to one-way.