[]previous and next page

The rules

These are the rules the repository enforces on its own packages (the list below is the part that matters to extension authors).

For agentsUpdated

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 on typeof window.
  • Create computations only after that guard, since effects never run there.
  • Do not read browser-only globals during setup; use onMount or an effect.
  • Give anything that needs identity across server and client a deterministic source (useId, an explicit key), never a counter or Math.random().
  • Components that create DOM nodes imperatively must claim server elements while hydrating. The built-in Dynamic shows the pattern: on the server it renders markup with ssrElement(..., needsKey = true); on the client it calls claimElement(nextKey()) and falls back to document.createElement, then binds its children with a region that adopts the server's content (takeRegion). Read packages/web/src/control-flow/dynamic.ts before writing one.