Skip to content

Development and Production Builds ​

The packages in the table below each publish two builds of the same code. Your bundler picks one automatically through the development export condition: a dev server or a development build resolves dist/development/index.js, a production build resolves dist/index.js. There is nothing to install, configure, or opt into. Server runtimes that run without a bundler choose differently; see Server runtimes.

The two builds behave identically except for one thing: the development build carries console warnings that the production build does not. Those warnings are not merely disabled in production — import.meta.env.DEV is substituted with a literal at emit time, so the guarded bodies fold out of dist/*.js entirely, message strings included. A production bundle cannot be made to print them.

Which packages ship it ​

PackageTwo buildsWhy
lit-ui-routeryesthree development warnings
lit-ui-router-effectyesone development warning
lit-ui-router-mobxyesone development warning
lit-ui-router-ssryesten development warnings; also gated under node
navigation-location-pluginnono runtime warnings to fold out
ui-router-serveryesone development warning; also gated under node
eslint-plugin-lit-ui-routernoa lint plugin — it never runs in your app bundle

The split exists where there is something to strip. A package with a single build is not missing a feature.

Every entry point of lit-ui-router is covered, not just the root: pure, register, ui-router.register, ui-view.register, and context each carry their own development condition.

The second gate: lit's own build ​

Every warning in lit-ui-router, lit-ui-router-effect, lit-ui-router-mobx and lit-ui-router-signals is gated twice. Past import.meta.env.DEV, it also checks that lit itself resolved to its development build, by probing for ReactiveElement.enableWarning — a method lit declares optional precisely because it exists only in development.

So a development build of these packages paired with a production lit stays silent. That pairing is unusual, but it is what a consumer gets when their bundler resolves the two packages under different conditions, and a warning that fires there would be noise rather than signal.

lit-ui-router-ssr skips this gate: a prerender script run with plain node resolves lit's production build, so its warnings would never fire.

The warnings ​

lit-ui-router ​

WarningFires whenFix
uiSref wrote an inert hrefassignHref: true (the default) writes href to an element with no href in HTMLPass assignHref: 'auto' to scope it to real links — see Design System Links
uiSrefActive took over an aria-currentthe directive finds an aria-current it did not set, and will remove it when the state goes inactivePass ariaCurrentValue: false to keep the attribute yours
no <ui-router> ancestora <ui-view>, uiSref, or uiSrefActive finds no router after its seek has actually runWrap the subtree in <ui-router>, or pass a router explicitly

The missing-router warning fires once per element across all three sites, not once per binding: a single missing provider trips uiSref's render and its click on the same element, and a wall of near-identical messages obscures the one fix.

lit-ui-router-mobx ​

WarningFires whenFix
no <ui-router> ancestora RouterReactionController's host has no <ui-router> above itWrap the subtree in <ui-router>, or pass options.router

This one has a consequence the message does not state: the controller then observes nothing. .value stays at options.initialValue for the life of the host, and the host is never asked to update — so the symptom is a component that renders once with its initial value and never again.

ui-router-server ​

WarningFires whenFix
a mount answers its bare base with a 404createServerRouter compiles a mount other than / whose bare base (/app) resolves to notFound or an otherwise shell-404Add a url: '' route, or a redirect rule matching the empty subpath such as /^\/?$/

It is a development-only warning, but the check is not. diagnoseMounts(router) returns the same findings as data in every build, so asserting it is empty in a test catches the problem in CI, whichever build the test resolves. See Server Route Matching.

A mount that answers its bare base with a 404 on purpose lists the kind in its ignoreDiagnostics, which turns it off for that mount in the warning and in diagnoseMounts alike.

Server runtimes ​

Server runtimes pick a build by their own export conditions. lit-ui-router-ssr and ui-router-server map node to their development builds too, so a prerender script or a Node server reports what it skipped. A production condition ahead of node takes the production build back, so a production server bundle carries no warnings. Each warning in the development build also asks the package's #dev-gate import (imports in its package.json): under node it reads process.env.NODE_ENV when the warning would fire and stays silent for production; under every other condition it always answers yes. deno keeps the production build, because reading NODE_ENV there needs --allow-env.

The setup we run ​

lit-ui-router.dev serves its sample apps through ui-router-server on Cloudflare Workers, and every change to this repo is tested on that path:

  • Develop with Vite. Build the router in the Vite config and hand it to serverRouterPlugin, as the site's .vitepress/vite.config.ts does. The config loader resolves the development build, so warnings print in the terminal. Nothing to set.
  • Assert in CI. A unit test asserts that diagnoseMounts(createServerRouter({ mounts })) returns [], as apps/sample-app-routes/test/routes.test.ts does. It reports in every build, so it catches what a dev run never reaches.
  • Preview the Worker with wrangler dev on the development build, setting WRANGLER_BUILD_CONDITIONS on the command that starts it as below. The site's scripts/wrangler-dev.ts launcher does this by default.
  • Test the production build. The e2e suites serve the Worker with the conditions wrangler deploy uses, so they test the production build that ships; see run-e2e.ts.

Elsewhere, develop on the development build and deploy with NODE_ENV=production or a production condition. A Node or Bun server deployed with neither warns once at boot for each mount that 404s its bare base, as Express and React warn in the same case.

Other runtimes ​

Runtime or bundlerBuild it resolvesWarns
plain node, Bundevelopment, through nodeunless NODE_ENV=production
node -C developmentdevelopmentunless NODE_ENV=production
node -C productionproductionnever
vitest, jestdevelopmentunless NODE_ENV=production
Vite SSR dev, the Vite config loaderdevelopmentunless NODE_ENV=production
vite build --ssrproduction, through productionnever
webpack targeting nodethe build its mode names, through development or productiononly in mode: 'development'
esbuild or Rollup targeting nodedevelopment, with the NODE_ENV read bundled; production under the production conditionas the bundle's NODE_ENV says
Denoproduction, through deno; development under --conditionsonly under --conditions=development
Workers, browsers, esbuild or Rollup for the browserproduction; development under the development conditiononly in the development build

Only the node side of #dev-gate mentions process, so a Worker or browser bundle never contains it.

What to set ​

  • Bundling a server for production with esbuild or Rollup: pass the production condition (esbuild --conditions=production, or production in node-resolve's exportConditions). Without it, the bundle ships the development build and the NODE_ENV read.
  • Deploying on Node or Bun: set NODE_ENV=production, or run node -C production.
  • Development warnings on Deno or Workers: opt in with the development condition: Deno's --conditions=development, or WRANGLER_BUILD_CONDITIONS=development,workerd,worker,browser (wrangler's own list plus development) on the command that starts wrangler dev. Never set it in .env, which wrangler deploy also loads.
  • Vite, vitest, jest and webpack: nothing to set.

What still warns in production ​

Two warnings are not part of the split and ship in both builds: <ui-router> and <ui-view> each warn if another class already holds their tag name, name that class, then skip registration. That is a first-definition-wins degradation rather than a define() throw. A subclass of their own element passes silently: it is a deliberate extension, and it is how lit-ui-router-ssr/register takes <ui-view>. A foreign class usually means two copies of the package are loaded — worth saying in production, because it is a packaging fault rather than an authoring mistake.

Catching the same problems at author time ​

Two of the warnings above have static counterparts in eslint-plugin-lit-ui-router — the inert href and the silent aria-current takeover — alongside a rule for the directives' element-part-only constructor throw, which is unconditional and fires in both builds. Lint reports all three across the whole codebase at author time, including for code paths a production build will never warn about and a development run may never reach.

How the split is kept honest ​

Each package with two builds declares its development-only message prefixes in a dev-warnings.json beside its manifest. The check:dev-split task (@tools/oxc-emit) reads that list after a build and asserts both directions: every declared prefix appears in dist/development/*.js, and none appears in dist/*.js. Adding a warning behind the import.meta.env.DEV guard without declaring it fails the check — so the list cannot silently drift from the code, in either direction. The same task fails any emitted file that mentions process unless a package.json#imports node target reaches it.