This workspace holds four behaviorally identical sample applications — the same non-trivial ui-router lit app, written four times so the reactivity idioms can be compared file-by-file, in the spirit of TodoMVC and the ui-router sample apps — plus the code they share and the test suite that keeps them identical. Portions are derived from the ui-router sample apps (MIT); see THIRD_PARTY_NOTICES.md.
| Package | What it is |
|---|---|
sample-app-lit-vanilla |
The zero-dependency idiom: lit-ui-router's TransitionController. Served at /app/ on the docs site. |
sample-app-lit-mobx |
The MobX idiom: lit-ui-router-mobx's reaction controllers. Served at /app-mobx/. |
sample-app-lit-effect |
The Effect idiom: SubscriptionRefs and the app-local ref controllers in src/app/effect/. Served at /app-effect/. |
sample-app-lit-signals |
The TC39 Signals idiom: @lit-labs/signals and lit-ui-router-signals' RouterSignalController. Served at /app-signals/. |
sample-app-shared |
Private, source-only package with everything the apps have in common: feature modules, router config, styles, fixtures. |
sample-app-lit-e2e |
One Playwright suite, run against all four apps — this is what enforces the behavioral identity. |
Each app keeps only the handful of modules that express its idiom. Its
src/main.ts provides them to the shared code via the typed DI contract
(registerAppModules in sample-app-shared/app/global/appModules.ts), then
boots the shared entry point. See the
sample-app-shared README for how the
inversion works and how to grow the shared surface.
| Module | sample-app-lit-vanilla |
sample-app-lit-mobx |
sample-app-lit-effect |
sample-app-lit-signals |
|---|---|---|---|---|
global/appConfig.ts, global/authService.ts |
Plain singletons | MobX observables (makeAutoObservable) |
SubscriptionRefs behind the sync contract; login runs as an Effect on a ManagedRuntime |
Signal.States in appConfig; login is a plain promise |
main/App.ts |
TransitionController re-renders on router transitions |
RouterReactionController observes the router via the observable RouterStore |
RouterRefController follows the router's SubscriptionRef<RouteSnapshot> |
RouterSignalController selects the active sticky sections, with an equals comparer |
main/NavHeader.ts |
TransitionController |
ReactionController selector over the observable auth state |
RefController selector over the auth ref |
SignalWatcher re-renders on the auth signals read in render() |
mymessages/Compose.ts |
TransitionController reads the message param per transition |
RouterReactionController reacts to the observable route param |
RouterRefController selects the message param off the route ref |
RouterSignalController onChange resets the draft from the message param |
mymessages/MessageList.ts |
StoreCommitController re-renders on the fake REST store's commit events |
ReactionController selector over the observable message cache |
RefController selector over the messages ref |
watch() binds a computed of the messages cache |
| Store helper | util/storeCommitController.ts — a ReactiveController wired to store commit events |
mymessages/messagesStore.ts — an observable cache refreshed on store commit events |
mymessages/messagesStore.ts — a ref refreshed by a daemon fiber over the store's commit stream |
mymessages/messagesStore.ts — a Signal.State cache refreshed on store commit events |
| Reusable glue | none (the controller ships in lit-ui-router) |
none (the controllers ship in lit-ui-router-mobx) |
src/app/effect/ — one ManagedRuntime, the two controllers, the lazily attached route ref |
none (the controller ships in lit-ui-router-signals) |
Everything else — the contacts/mymessages/prefs features, router
configuration, transition hooks, data sources, dialogs, styles, and the
simulated REST fixtures in public/ — is imported from sample-app-shared.
The feature set tracks the ui-router sample apps, with one deliberate
addition: 404 handling. The
angularjs sample silently
redirects unmatched URLs to /welcome; the
angular and
react samples don't handle
them at all. This app routes them to a dedicated notFound state that
preserves the URL, plus a transition hook that keeps the 404 from being
superseded when a URL matches a lazy-loaded future state's prefix but nothing
inside the loaded module. See the
Unmatched URLs (404) guide
for the full pattern.
All four apps support three location strategies via locationPluginConfig in the
shared router.config.ts:
| Plugin | URL Format | Browser Support |
|---|---|---|
hash |
/#/path |
All browsers |
pushState |
/path |
Modern browsers |
navigation |
/path |
Chrome/Edge 102+, Firefox 147+, Safari 26.2+ |
Default: with no preference set, the app selects the best available plugin:
- Navigation API when supported, via the navigation plugin
- Push State as fallback for unsupported browsers
Override options (in priority order):
- URL parameter:
?feature-location-plugin=hash - Session storage: Set via Feature Flags panel in Prefs
- Environment variable:
VITE_SAMPLE_APP_LOCATION_PLUGIN=pushState
default is accepted at every level and means "no preference", so
?feature-location-plugin=default restores the auto-detected plugin without
clearing the session flag or the env var underneath it.
The Feature Flags panel shows browser compatibility indicators for each plugin option.
turbo dev starts every app's Vite dev server on a pinned port —
sample-app-lit-vanilla on :5173, sample-app-lit-mobx on :5174,
sample-app-lit-effect on :5175, sample-app-lit-signals on :5176 — with
strictPort set, so a taken port fails loudly instead of silently drifting to the next free one. No server
auto-opens a browser. To run one app on a different port, override it on the
CLI:
pnpm --filter sample-app-lit-vanilla dev --port 5273sample-app-lit-e2e runs one Playwright suite against all four apps, the docs site,
and every published location strategy. See the
sample-app-lit-e2e README.
The docs site runs the sample app behind several mounts — /app,
/not-found-naive, /not-found-spa, /simulated-routing — to exhibit points
on the server-support spectrum. All
four share a single vanilla build: the client recovers its base prefix at
boot rather than having one baked in, so one build deep-links correctly under
every mount. Assets emit at absolute /assets/…, so nothing else varies by
prefix.
Four builds stay separate, because they differ in more than a base prefix — but they recover their base the same way, so none bakes one either:
| Build | Serves | Location |
|---|---|---|
sample-app-lit-vanilla dist/vanilla |
/app, /not-found-naive, /not-found-spa, /simulated-routing |
pushState |
sample-app-lit-mobx dist |
/app-mobx |
pushState |
sample-app-lit-effect dist |
/app-effect |
pushState |
sample-app-lit-signals dist |
/app-signals |
pushState |
sample-app-lit-vanilla dist/hash |
/app-hash |
hash |
Recovering the base. @uirouter/core's pushState location reads its base
from the <base href> tag at plugin-install time
(browserLocationConfig.getBaseHref() → document.getElementsByTagName('base')).
configureRouter creates that element at boot, deriving the href from
location.pathname — the longest mount prefix the path sits under — and falling
back to the build-time VITE_SAMPLE_APP_BASE_URL when served off a known mount
(the dev server at the root):
// router.config.ts
import { shellMounts } from 'sample-app-routes/shell-mounts';
const path = location.pathname;
const derived = shellMounts
.filter((m) => path === m || path.startsWith(`${m}/`))
.sort((a, b) => b.length - a.length)[0];
const BASE_URL =
(derived && `${derived}/`) || import.meta.env.VITE_SAMPLE_APP_BASE_URL;
if (BASE_URL) {
const el = document.createElement('base');
el.href = BASE_URL;
document.head.appendChild(el);
}The derivation is gated on known entries: an unmatched prefix yields no derived
base, so the app boots at / rather than mis-stripping, and the + '/'
boundary keeps /app from swallowing /app-hash, /app-mobx,
/app-effect or /app-signals. So
/app/welcome → base /app/ → matches /welcome; /not-found-naive/x → base
/not-found-naive/ → otherwise → the in-app 404 (the soft-404 exhibit);
/simulated-routing/welcome → base /simulated-routing/. On the serving side
the docs worker points every vanilla mount at the one shell (shellPath), and
the dev server's SHELL_PATHS does the same.
shellMounts lives on its own subpath. The base-prefix list is a
dependency-free module (sample-app-routes/src/shellMounts.ts) exposed as the
sample-app-routes/shell-mounts subpath, deliberately not re-exported from the
routes.ts barrel. That barrel imports ui-router-server for its MountConfig
types; importing it from the client would pull the server package's source-only
.ts across the tsconfig boundary (the apps don't set
allowImportingTsExtensions). The subpath keeps the client free of
ui-router-server — it reads only plain strings. sample-app-routes/test pins
shellMounts as an exact mirror of the mount keys plus /not-found-naive
(which has no MountConfig — the worker serves it with routing off), so a new
or renamed mount can't silently miss its base.