The rules
These are the rules the repository enforces on its own packages (the list below is the part that matters to extension authors).
These are the rules the repository enforces on its own packages (the list below is the part that matters to extension authors). Code that follows them behaves like the built-ins under SSR, hot reload and long-running servers.
1. User callbacks run untracked#
A function you receive from a user (a children render function, an onSelect, a compute that is not
meant to be tracked) must not subscribe the running computation to what it happens to read. Wrap the call with
untrack (core) or runInScope({ listener: null }, fn) (extend), or place it in the untracked stage of an
effect (apply), as the hooks above do.
2. Every "set current X" is restored in finally#
The current owner and listener are registers. Never assign them; use runInScope, which restores both in a
finally, so a throwing callback cannot leave the engine pointing at a disposed owner:
The same discipline applies to your own state that follows a scope (a "current form", a "current theme"):
save, set, try, and restore in finally.
3. No module-level mutable state#
Module scope is shared by every request on a server and by every test in a file. State that belongs to a
tree belongs in a context (createContext) or in a closure created per call (the signal inside
useToggle). Caches that must live at module scope are bounded and keyed (a WeakMap on the object that owns
them). Do not count instances, remember the last value or queue work in a module variable.
4. Everything with a lifetime has an owner#
Effects, memos and cleanups attach to currentOwner(). Register teardown with onCleanup (or
addCleanup(owner, fn) for an owner you hold); create a scope you manage with createOwner(parent?) and end it
with disposeOwner(owner), which disposes children first and runs cleanups newest first, each in isolation
(one throwing cleanup does not stop the rest). When your primitive creates an effect outside any owner, raise
warn("NO_OWNER_EFFECT") like useEffect does.
5. Errors go somewhere#
Route errors from callbacks you call outside the engine's own flush (timers, events, promises) with
handleError(error, ownerYouCaptured). Inside effects and memos, let them throw: the engine routes them.
Never swallow an error you did not expect.
6. Diagnostics use the shared channel#
warn(code) is deduplicated per code and goes to the warning listener if one is set
(setWarningListener(listener) returns the previous listener; restore it in finally), otherwise to
console.warn. The listener is what @shadow-js/testing's captureWarnings and devtools use, so a diagnostic
you raise through warn is captured by them without extra work. ShadowError carries a stable code.
Messages exist in development; production builds keep only the code (SHADOW_<CODE>).
The catalog (MESSAGES) is a fixed table in this version: you can raise any code in it, and the list is in
the diagnostics FAQ. You cannot register codes of your own. For a condition no existing code describes, throw your own Error
with a clear message, guarded by isDev for development-only checks, and do not invent a ShadowError code.
7. Be correct on the server#
- Guard side effects with
isServer; do not rely ontypeof window. - Create computations only after that guard, since effects never run there.
- Do not read browser-only globals during setup; use
onMountor an effect. - Give anything that needs identity across server and client a deterministic source (
useId, an explicitkey), never a counter orMath.random(). - Components that create DOM nodes imperatively must claim server elements while hydrating. The built-in
Dynamicshows the pattern: on the server it renders markup withssrElement(..., needsKey = true); on the client it callsclaimElement(nextKey())and falls back todocument.createElement, then binds its children with a region that adopts the server's content (takeRegion). Readpackages/web/src/control-flow/dynamic.tsbefore writing one.