Skip to content

Troubleshooting

webjs's no-build, isomorphic-module model produces a few error signatures you have not seen in a bundled framework: a module that throws at LOAD rather than at call, a 500 from a stray backtick, a strip-time failure that points at a lint rule. This page is keyed by SYMPTOM. Find the error text or the behavior you are seeing, then read the cause and the fix. Most of these are also caught ahead of time by webjs check, so run it first.

"Cannot import X from browser code. This file is server-only"

Symptom: the page goes blank and the browser console shows an error thrown at module load, naming a .server file, before any of your code runs.

Cause: you imported a server-only utility (a .server.{js,ts} file with NO 'use server' directive) directly into a page, layout, or component. The dev server resolves that browser import to a stub whose body throws at the top level, so it fails the instant the module loads, not when you call the function. This is deliberate: it keeps the server source (your database connection, secrets, node:* usage) off the client.

Fix: never import a no-'use server' util straight into client-bound code. Use it INSIDE a 'use server' action, a route.{js,ts} handler, or middleware.{js,ts} (all server-only), and have the page call that action. A page reaches server logic through an action whose RPC stub loads safely on the client. See Server Actions. This is framework invariant 1 (the .server boundary). The no-server-import-in-browser-module check rule catches this ahead of time, on any page, layout, or component the build determines will ship to the browser (a display-only page the framework elides is not flagged). A TYPE-ONLY import type { Row } from './x.server.ts' is exempt, because the TypeScript stripper erases it before it reaches the browser, so sharing a derived row type from a .server.ts is safe and is not flagged.

A 500 in production from an html template that worked in dev

Symptom: a page renders in dev but 500s in production, or throws a cryptic JavaScript parse error near a template.

Cause: a backtick character appears inside an html`...` (or css`...`) template body, even inside a CSS or HTML comment. A nested backtick closes the tagged-template literal at JavaScript parse time, so the rest of the file is misparsed.

Fix: remove the backtick from the template body. If you need a literal backtick in rendered output, build it from a string expression (${'`'}) rather than typing it inline. This is framework invariant 9.

A 500 at strip time pointing at no-non-erasable-typescript

Symptom: a .ts file 500s with a message about TypeScript stripping and the no-non-erasable-typescript rule, or the server refuses to start naming a required Node version.

Cause: WebJs strips types with Node 24+'s built-in module.stripTypeScriptTypes, which only erases TYPES. Non-erasable syntax (an enum, a namespace with values, a constructor parameter property, a legacy decorator with emitDecoratorMetadata, or import = require) has no type-only form to strip, so it fails. The Node-version variant means you are on Node below 24, where the built-in strip and recursive fs.watch are unavailable.

Fix: set compilerOptions.erasableSyntaxOnly: true in tsconfig.json so the compiler rejects these at edit time, and use the erasable equivalents (a const object plus a union type instead of an enum, an explicit field plus assignment instead of a parameter property). Upgrade to Node 24+ for the version error. See TypeScript. This is framework invariant 10, enforced by the erasable-typescript-only and no-non-erasable-typescript check rules.

An SSR crash naming document, window, or a DOM method

Symptom: the server render throws naming a browser global or an HTMLElement method (document, window, localStorage, this.querySelector, this.classList, this.attachShadow), and the page never reaches the browser.

Cause: a component touched a genuinely browser-only global in its constructor or render(), both of which run during SSR. The SSR pipeline constructs the component and calls render() on the server, where the live DOM does not exist. (The attribute methods, closest(), and the host IDL reflections ARE backed by a server shim and do not crash; only the live-DOM surface does.)

Fix: move browser-only reads (localStorage, viewport, matchMedia, navigator.*) into connectedCallback, which runs only in the browser, then write the value to a signal to refine the first paint. Defaults for the first paint go in the constructor; server-known data comes through the page function as a prop. See Components and Progressive Enhancement. The no-browser-globals-in-render check rule catches this ahead of time.

A custom element renders but never reacts to a property change

Symptom: the component paints correctly on first load, but assigning a reactive property later does not re-render it.

Cause: you wrote a class-field initializer for a factory-declared reactive property (count: number = 0 or count = 0 alongside WebComponent({ count: Number })). Under modern class-field semantics that compiles to a define on this AFTER super(), which overwrites the framework's reactive accessor, so subsequent assignments bypass the update.

Fix: remove the class-field initializer and set the default by assigning in the constructor after super(). See Components. The reactive-props-no-class-field check rule flags this.

A component method named append, remove, or appendChild silently never runs

Symptom: a handler or helper you defined on a component (an append() click handler, a remove() method) does nothing when called, with no error thrown, and TypeScript did not complain.

Cause: WebJs instruments the native DOM mutation methods on every light-DOM host to keep the slot API live, and it installs those interceptors as own properties ON THE ELEMENT INSTANCE. An own instance property shadows a method defined on the class prototype, so the framework's interceptor wins and your same-named class method is never reached. The instrumented names are append, prepend, before, after, replaceWith, replaceChildren, remove, appendChild, insertBefore, removeChild, and replaceChild. TypeScript does not catch it because a shorter override (zero or one argument) is assignable to the native signature.

Fix: rename the member to a non-native name (appendRow(), removeItem()) and update its call sites. Only these native mutation members are affected; render() and the lifecycle hooks are meant to be overridden. The no-shadowed-native-member check rule catches this ahead of time (it flags only a real instance method, never a static member, a nested-object property, or a static shadow = true component, whose native shadow slots are not instrumented). See Components.

An error saying a function was interpolated into action=, or that it is not a server action

Symptom: a render fails with a function was interpolated into action=, or with the function in action= is not a server action. The supported binding is an unquoted action=${fn} on a <form>, where the function is imported from a 'use server' module; the first message means the shape was a near-miss the renderer will not stringify, and the second means the shape was right but the function is not an action the server can run. Where that surfaces depends on which render threw. From a page or layout it propagates, so the nearest error boundary catches it and the message is visible. From inside a component it does not: per-component SSR error isolation contains it, so dev renders an error box in place of the component while production renders the component empty and the page still returns 200. A form that has silently vanished in production, with no error anywhere, is the same bug wearing a disguise; check the server log for the message. In neither case is the action's source emitted.

Two refinements on "renders the component empty", because both send people looking in the wrong place. The isolation replaces the element through its matching closing tag, so anything slotted into that component disappears with it: put the bad form in a shared header or shell and every page returns 200 with an empty body, which reads like a routing failure rather than one broken component. And on a route with a loading.ts, the page renders inside a Suspense boundary after the 200 and the shell are already flushed, where a throw is currently swallowed with no server log and no error report; the visitor gets chrome and a blank body, and with JS off the loading skeleton simply stays. That silence is why the log line you are looking for may not exist on that path; it is a known gap rather than intended behaviour.

Cause: during SSR an imported 'use server' action is the REAL function (the RPC stub exists only in the browser). The supported binding resolves that function's identity instead of stringifying it, but in any OTHER shape action= is an ordinary attribute hole, so stringifying it would write the action's whole body into the HTML every visitor downloads: its logic, the query shapes it builds, and any literal written inside it, a connection string or internal path included.

Assume outer values are exposed too. On Node, Function.prototype.toString returns source text, so a module-scope const the body reads appears only as its identifier. That is NOT a guarantee you can rely on: Bun transpiles a module before the engine sees it and can fold a module-scope string literal straight into the body, so the same action reports Authorization: "Bearer sk_live_…" where Node reports Bearer ${VENDOR_API_KEY}. Do not go looking for the rule that decides when it folds. It is a transpiler's internal choice rather than a documented boundary, and two modules on the same Bun version can differ. Whether a secret in an outer binding escapes therefore depends on the runtime and on how the module was transformed, and it is not a boundary worth building a habit on. Treat everything reachable from the action as exposed, which is what the refusal assumes. WebJs refuses rather than emitting it. The refusal covers every shape that is not the binding: an unsupported formaction= shape, action=${fn} on a tag that is not a <form>, a quoted action="${fn}" or mixed action="/x/${fn}" (quoting turns a binding hole back into a plain attribute), and a function wrapped in an array (action=${[fn]}), which stringifies its elements the same way. One shape it does not cover: a hole inside an HTML comment is emitted raw by the renderer, so commenting a working form out does NOT disable the interpolation, it turns the binding back into a leak. Delete the form rather than commenting around it. .action=${fn} on a native form is refused at SSR too, even though the property is dropped there, so a page cannot render clean on the server and then throw on hydration, where the reflected IDL attribute would carry the source into the live DOM.

Two things stay legal, because neither stringifies its value: a custom element's .action property (an author-defined property, not the reflected IDL attribute a native <form> has, so <my-el .action=${fn}> is fine), and an unquoted @action=${fn} event listener, where a function is exactly what is wanted. One qualification on the first: that holds for a plain property, and NOT if the component declares it reflect: true. Reflection is a separate path that does not pass through the template commit sites this guard covers, so it carries its own guard: a reflecting action prop removes its attribute and warns rather than emitting the function's source. That covers every function-valued reflected prop rather than an action one, and it is described under "A reflected attribute is missing, with a warning that the property holds a function" below. Quoting a binding hole turns it back into a plain attribute, so @action="${fn}" IS refused; that is the practical reason invariant 4 requires @, . and ? holes to be unquoted. ?action=${fn} is refused as well: it never leaked, but a truthy function would silently emit a bare action="". The HTML spec requires action to be a valid non-empty URL when the attribute is present, so an empty one is a conformance error rather than merely a pointless value. Deleting the attribute is not the fix here, though: a page has no action export, so a form that binds nothing and posts is the 405 described below, conformant and still broken (a bare GET form merely re-renders the page). Bind the action, <form action=${fn}>, which is what emits the identity the dispatcher reads.

Fix: write the binding exactly: an unquoted action=${importedAction} on the <form> itself, or formaction=${importedAction} on a <button> inside a bound form, with the function imported from a 'use server' module. Use a <button> rather than an <input type="submit">, whose value is both the identity channel and the visible label. Do not add submitter name, value, form, or static formaction attributes. Do not add method or enctype; the renderer supplies both. A string action is unaffected. See Server Actions and Progressive Enhancement.

A form submission answered with a 405

Symptom: submitting a form returns 405 Method Not Allowed with Allow: GET, HEAD, and the page itself renders fine on a GET.

Cause: the form binds no action. A page has no action export, so a bare <form method="post"> has nothing to run: the url exists and only renders, which is what the 405 says. The other way to get one is binding an action whose file declares export const method = 'GET'; a GET action rides its arguments in the url and skips the CSRF check, so it cannot answer a form POST. That case answers Allow: GET, and webjs check's form-action-not-a-get-action rule catches it before it ships.

Fix: bind the action (<form action=${submitFeedback}>), or drop the method export from the action's file so it is an ordinary POST.

A button submits and the page just re-renders

Symptom: clicking a formaction=${action} button reloads the same page with a 200, nothing was written, no error appears anywhere, and the address bar has grown a ?__webjs_action= parameter.

Cause: the submission went out as a GET, so it carried no body and the identity rode the query string instead, and a GET at a page url just renders the page. That is why there is no 405 and no log line. A submitter you bound with formaction=${action} does NOT reach this on its own: the renderer gives it formmethod="post", which overrides the form's method per HTML, so it posts correctly inside a form that is unbound, method-less, or even method="get". What reaches this is a formmethod="get" or method="get" written by hand and deliberately honoured, or a hand-authored form carrying the reserved field.

Fix: remove the explicit formmethod="get" or method="get" from the submission path you meant to write. Two runtime signals find it: in dev the browser console carries one [webjs] error at submit time naming the fix, and in production the onError hook receives the fingerprint with err.code === 'WEBJS_FORM_SUBMITTED_AS_GET'. The 405 case has its own code, WEBJS_FORM_ACTION_MISSING. Neither changes the response.

A render fails on a button's formmethod or formenctype

Symptom: a page fails at render with <button formenctype="text/plain" formaction=${action}> cannot work, or the same message naming formmethod.

Cause: that button BINDS an action and also tells itself to submit in a way the action could never read. A bound action needs a POST body the server can parse as multipart/form-data or application/x-www-form-urlencoded. A GET sends no body at all, text/plain is flagged by the HTML spec as not intended for machine parsing, and formmethod="dialog" dismisses a <dialog> rather than submitting. The renderer supplies formmethod="post" and the enctype on a bound submitter, so writing a conflicting value on the same button is a straight contradiction.

Fix: drop the attribute and let the renderer supply it, or drop the binding. Note this fires only on a button that binds an action. A button that binds NOTHING keeps whatever you wrote, because a submitter's override is legal HTML and means what you typed. If such a button is inside a bound form, its override defeats that form's action, and the page will log a console error at submit time in dev saying the submission is carrying an identity it cannot deliver.

A per-button action does nothing and the page just reloads

Symptom: clicking a formaction=${action} button re-renders the same page with a 200, nothing was written, and there is no error anywhere.

Cause: the submission went out as a GET, so the action identity rode the query string instead of a request body and no action ran. Before the submitter carried its own formmethod this happened whenever the enclosing form was unbound, which is fixed. What can still cause it is an explicit override you wrote: a formmethod="get" on the button that was pressed, or on the form, wins over everything else by native precedence and WebJs honours it rather than refusing it.

Fix: remove the formmethod="get", or move that button out of the form whose action you wanted to run. In dev the client logs a console error naming this at submit time; in production it reaches your onError hook as WEBJS_FORM_SUBMITTED_AS_GET.

A form re-renders asking you to submit again

Symptom: a submission comes back at 422 with a message about the page having been updated, and the typed values still filled in.

Cause: the identity the form submitted names an action file this build does not have. That is deploy skew: the form was rendered by an older build and submitted against a newer one, so the hash no longer resolves. Re-rendering the page is the deliberate answer, because a 404 would discard everything typed and a silent success would report a write that never happened.

Fix: nothing to fix; submitting again works, and the values are still there. If you see it constantly rather than around a deploy, check that the app is not being served by two processes on different builds.

A reflected attribute is missing, with a warning that the property holds a function

Symptom: a property declared reflect: true renders no attribute at all, and the console carries reflect:true property "x" on <my-tag> holds a function (or an array carrying one), which has no HTML attribute representation. The warning appears in the server log for an SSR render and in the browser console for a client-side assignment, since both run the same reflection path.

Cause: the property was assigned a function, and a function has no HTML attribute representation. Writing one anyway is worse than dropping it. String(fn) is the function's SOURCE, so a reflected 'use server' action would put its whole body, closure secrets included, into the HTML every visitor downloads, and an Object-typed or Array-typed property would get JSON.stringify(fn), which is undefined and lands in the attribute as that literal four-character string. So reflection treats a function like null and removes the attribute. This is not specific to a property named action; it holds for every name, because the leak was the generic reflection path stringifying whatever it was handed.

Fix: decide what the attribute was for. If the function was meant as a callback, drop reflect from the declaration and keep it as a plain property or a signal, which is where a function belongs. If the attribute is genuinely wanted, assign a string that names the behaviour rather than the function itself. If you own the serialization and know what you are doing, a custom converter.toAttribute runs before this guard and is left alone, so it still receives the function and decides for itself. To bind a server action to a form, use <form action=${importedAction}>, which resolves the action's identity instead of stringifying it. See Components.

A reflected attribute is missing, with a warning about a value JSON.stringify cannot serialize

Symptom: an Object-typed or Array-typed property declared reflect: true renders no attribute, and the console carries reflect:true property "x" on <my-tag> holds a value JSON.stringify cannot serialize (a cycle, a BigInt, or a throwing toJSON), so it has no HTML attribute representation. The SSR form of this arrives differently and is the one people usually hit first. Before the guard existed the throw was contained by per-component SSR error isolation, so in dev the component was replaced by a red error box carrying the message, and in production it rendered EMPTY on a page that still returned 200 with the cause only in the server log. A component that has silently vanished in production is therefore the same problem wearing a disguise.

Cause: the value cannot pass through JSON.stringify at all. Three shapes do this in practice: a cycle (an object or array that reaches itself, which arrives from a parent/child graph, a linked node, a memo table, or anything a library hands back with a back-reference), a BigInt anywhere inside the value, and an author toJSON() that throws. An HTML attribute is a string, so a value with no string form has no attribute representation, and reflection removes the attribute instead. This is a different case from a JSON-typed value merely CARRYING a function, which keeps its data: [1, 2, fn] serializes to [1,2,null] and reflects normally. The property itself is untouched and still holds the value in memory; only the attribute goes, because the attribute is a projection of the property rather than the source of truth for it. On the read side, an attribute that is PRESENT but not parseable JSON reads back as null rather than as the raw string. An attribute that is absent is not read at all, so the property keeps its constructor value instead.

Fix: break the cycle before assigning, or reflect a derived scalar instead. A reflected attribute is usually wanted for CSS or a selector, and an id or a count serves that better than a whole graph: declare selectedId: prop(String, { reflect: true }) and keep the graph on a plain non-reflected property or a signal. For a BigInt, convert it to a string at the boundary. For a throwing toJSON(), fix the method or drop reflect. If you own the serialization, a custom converter.toAttribute runs before this guard and is left entirely alone. See Components.

An error saying static properties is no longer supported

Symptom: a component throws at construction with static properties is no longer supported. Declare reactive properties via the factory instead.

Cause: the class body has a hand-written static properties = { ... } field. WebJs declares reactive properties only through the WebComponent({ ... }) base-class factory now, and the runtime throws on a direct static properties.

Fix: move the properties into the factory call (class X extends WebComponent({ count: Number })). Use the prop() helper for options (prop(Number, { reflect: true })) and set defaults in the constructor. Delete the static properties block and any matching declare fields. See Components. The no-static-properties check rule flags this ahead of the runtime throw.

A component's first paint is empty until JavaScript runs

Symptom: a component shows a blank or skeleton state on first load and fills in only after hydration, and with JavaScript disabled it stays empty.

Cause: initial data is fetched in connectedCallback or firstUpdated, neither of which runs during SSR, so the server-rendered HTML is empty by design. This breaks progressive enhancement.

Fix: fetch the data on the server in the page function and pass it down as a prop (.prop=${richObject} for a custom element, which round-trips through SSR, or an attribute for a native element). Reserve connectedCallback for genuinely browser-only refinement. See Progressive Enhancement.

A whole page gets replaced on a frame navigation

Symptom: clicking a link inside a <webjs-frame> replaces the entire document instead of just the frame (for example an auth redirect returning a login page).

Cause: the navigation response did not contain a <webjs-frame> with the requested id. Rather than silently swapping the whole document, WebJs now fires a cancelable webjs:frame-missing event and leaves the frame unchanged (with a console warning).

Fix: make sure the response for a frame-scoped navigation includes the matching frame, or listen for webjs:frame-missing on document and decide the outcome yourself (the event detail carries { frameId, url, document }; call preventDefault() to take over, for example a full navigation with navigate(detail.url)). See the <webjs-frame> section in Client Router.

A vendor module 404s after deploying under a sub-path

Symptom: the app works at the origin root but, when mounted under a sub-path (example.com/app/) behind a proxy that does not strip the prefix, the importmap and module URLs 404 and the page never hydrates.

Cause: the framework-emitted URLs (importmap targets, the boot module specifiers, the RPC endpoint) default to origin-root paths.

Fix: set "webjs": { "basePath": "/app" } in package.json. WebJs strips the prefix at ingress and prefixes every framework-emitted URL, so module resolution works under the mount. See Configuration.

A browser import of an app file returns 404

Symptom: a module the browser tries to fetch returns 404 even though the file exists.

Cause: only files reachable from a browser-bound entry (a page, layout, error, loading, not-found, or component) through the static import graph are servable. A file nothing client-side imports, a hand-rolled scripts/ helper, or a .server file's source returns 404 by construction, the same posture as a bundler manifest.

Fix: import the module from a browser-bound entry so it enters the graph, or, if it is server-only, keep it behind the .server boundary and reach it through an action. See No-Build Model.

A REST endpoint for a server action 404s

Symptom: a 'use server' action is RPC-callable from a component but curling a path returns 404.

Cause: a server action is reachable over RPC, not over an arbitrary REST path on its own. REST endpoints go through route.ts (the framework's HTTP handler).

Fix: add an app/<path>/route.ts that imports the action and calls it, or wrap it with the route() adapter from @webjsdev/server (export const POST = route(myAction)). See Server Actions.

A component's static styles have no effect (and a console warning)

Symptom: a static styles = css`...` block does not style the component at all, and the framework warns at runtime.

Cause: static styles is adopted through a shadow root, but the component is in light DOM (the default), which has no shadow root, so the stylesheet is never adopted and the framework warns.

Fix: add static shadow = true to scope the styles, or use Tailwind utilities (unique by construction), or, if you keep custom CSS in light DOM, prefix every class selector with the component tag name (framework invariant 7 for light-DOM CSS). See Styling.

A vendored package throws "X is not exported" or a missing-symbol error at runtime

Symptom: two npm packages that work together locally throw at runtime in the browser, typically a missing-export or undefined-symbol error from one package reaching into another (for example @codemirror/lint calling into a @codemirror/view that is pinned an older minor than it needs).

Cause: the importmap pins each package to one version, and one pinned package declares a dependency or peer range on another pinned package that the pinned version does not satisfy. The graph is INCOHERENT: package A needs view ^6.42.0 but the importmap pins [email protected], so a symbol A expects is absent from the older bundle. This can come from a hand-edited .webjs/vendor/importmap.json, a partial vendor pin, or a stale resolve.

Fix: run webjs doctor. The importmap-coherence check inspects the produced importmap (the live one AND the vendored .webjs/vendor/importmap.json, with the same verdict for the same dependency set) and warns naming both packages, the required range, and the pinned version. Align the versions: re-run webjs vendor pin to re-resolve a coherent set, or bump the lagging package in package.json and reinstall so the importmap pins a version every dependent accepts. See No-Build Model.

A webjs.dev.before / webjs.start.before step fails and aborts the boot

Symptom: the dev or prod server refuses to start, printing webjs dev: before-step failed (exit N): <command> (or webjs start: ...) and never serving.

Cause: a webjs.dev.before / webjs.start.before step (the scaffold ships webjs db migrate) exited non-zero. As of #550, webjs dev / webjs start run these one-shot steps to completion BEFORE serving (replacing the old predev / prestart npm hooks), and they ABORT the boot on a failure so the app never serves a stale client or schema. A bare webjs dev and npm run dev run the same steps, so this is not a "wrong command" problem.

Fix: run the failing command directly to read its real error (for the scaffold's steps, webjs db generate or webjs db migrate), fix the cause (a malformed db/schema.server.ts, an unreachable DATABASE_URL), then re-run. A local binary in a step (drizzle-kit, tailwindcss) resolves under a bare webjs dev the same way npm run resolves it (the ancestor node_modules/.bin dirs are on the step's PATH).

A 'use server' file's exports are not callable from a component

Symptom: you added 'use server' to a plain .ts file and imported one of its functions into a component, but the call fails or webjs check flags use-server-needs-extension.

Cause: the RPC boundary keys on the FILE EXTENSION, not the directive alone. A 'use server' directive in a plain .ts file is a lint violation, because the file router only treats .server.{js,ts} files as the server boundary. Without the extension the source would also be served to the browser.

Fix: rename the file to *.server.ts. The extension makes it server-only (source-protected) and the 'use server' directive makes its exports RPC-callable. See Server Actions. The use-server-needs-extension check rule catches this, and use-server-exports-callable flags a 'use server' file that exports no callable function (it registers nothing, so the RPC 404s silently).

A configured server action file with more than one function is rejected

Symptom: webjs check flags one-action-per-configured-file on a *.server.ts that exports a config (method, cache, validate, middleware) alongside two callable functions.

Cause: the HTTP-verb and caching config exports (export const method, cache, tags, invalidates, validate, middleware) attach to the ONE action in the file, so a second callable function makes the config ambiguous.

Fix: keep one callable function per configured action file (the convention is one function per action file regardless). Split the second function into its own *.server.ts. See Server Actions. The one-action-per-configured-file check rule enforces this.

A nested layout or page's <html> shell is dropped

Symptom: you wrote <!doctype> / <html> / <head> / <body> in a non-root layout or a page and the tags vanish from the output, or webjs check flags shell-in-non-root-layout.

Cause: the framework auto-emits the document shell around the whole composition, so only the ROOT layout (app/layout.ts exactly) may write its own shell. A nested shell ends up inside the framework's <body> and the HTML parser drops the stray tags.

Fix: remove the shell tags from the nested layout or page and return just the content. Move any <html lang> / <body class> customization to the root layout, which the framework respects and splices its required tags into. See Routing. The shell-in-non-root-layout check rule flags this, framework invariant 8.

An import of a symbol crashes at runtime, but the checker was green

Symptom: a named import resolves to undefined and crashes on first use (often after renaming or removing a schema table), and type-checking reports has no exported member while the correctness checker passed.

Cause: most check rules are elision-based and do not compile types, so a stale import used to slip past. Dropping the example todos table while another module still imported it is the canonical case.

Fix: add the missing export, correct the imported name, or remove the import (and prune any orphaned module). The no-missing-local-import rule now flags a named value import of a symbol a resolvable app-internal module does not export, so it is caught before runtime. It is conservative by design (only app-internal specifiers, only named value imports, and it skips a module whose exports are not fully enumerable), so a re-export barrel and a 'use server' action file resolve normally. Type-checking alongside the correctness checker stays the belt-and-suspenders habit.

Still stuck

The framework source is in node_modules/@webjsdev/ with no build step, so what you read is what runs. Grep the relevant file (the SSR pipeline in @webjsdev/server/src/ssr.js, client hydration in @webjsdev/core/src/render-client.js, the convention rules in @webjsdev/server/src/check.js). Run webjs check to surface most of the issues above before they reach the browser, and run webjs check --rules to read what each rule enforces.