Skip to main content

Standalone Apps

A standalone app is a single-product build made from a slice of the shared libraries — AquaRecycle (uwms), Lakepulse (lakepulse), and AquaRain (rwi). This page explains exactly what makes an app standalone, and how the pattern has changed over time.

If you just want to know what each product is, read Products first. This page is about the code.


The three ingredients

An app becomes "standalone" through three small pieces of startup code. Nothing is loaded remotely — it is all decided at build time and passed into the shared store.

1. A per-app login check — createValidateLogin(navHelper)

Every app runs a login check on startup: read the stored token, refresh it if needed, and redirect to the right landing page. That logic lives in the shared splash controller:

// libs/components/src/pages/splash/splashController.js
export const validateLogin = ... // uses the generic NavigationHelper
export const createValidateLogin = (navHelper) => ... // uses an app-specific helper
  • The main app (production) uses the plain validateLogin.
  • Standalone apps call the factory createValidateLogin(<AppNavigationHelper>) so the login redirect uses their route table.

2. A per-app navigation helper — navInstance

Each standalone app has its own navigation helper class, and passes it into the layout:

// apps/uwms/src/routes/routes.js
<MainLayout navInstance={UwmsNavigationHelper} />

The main app renders <MainLayout /> with no navInstance, so it falls back to the generic helper. See Navigation helpers below.

3. A name for the shared store — standaloneAppName

The app tells the shared store which product it is:

<AppStoreContextProvider validateLogin={validateLogin} standaloneAppName="uwms">

Inside the store (libs/shared/src/store/AppStore.js), standaloneAppName becomes the "active water context" and gates product-specific flags (for example, isRwiNativeApp). The main app leaves it empty, so its context is driven by the logged-in account instead.

The short version

Main app = generic helper, no standaloneAppName, full route table. Standalone app = createValidateLogin(OwnHelper) + navInstance={OwnHelper} + standaloneAppName="<app>" + a small route table.


Store flags driven by standaloneAppName

Once standaloneAppName is set, AppStore (libs/shared/src/store/AppStore.js) derives a set of read-only flags that feature libraries use to adapt their UI. This is how one shared codebase behaves differently per product without any runtime module loading.

VariableDerived asWhat it means / where it's used
standaloneAppNameprovider prop — 'uwms' / 'lakepulse' / 'rwi', or nullWhich standalone app is running; null in the main production/demo app. Selects the nav helper (getActiveAppNavHelper(standaloneAppName)) and gates product UI (e.g. UwiPlantViewPage, FeatureLockedPage).
waterContext / setWaterContextuseState(null)Runtime water context for the main app — it has no fixed standaloneAppName, so it sets this when the user enters a uwi/rwi sub-experience.
activeWaterContextstandaloneAppName ?? waterContextThe effective context: the standalone app name wins; otherwise the main app's runtime waterContext.
isRwiNativeAppstandaloneAppName === 'rwi'True only inside the AquaRain standalone app.
isFromRainwateractiveWaterContext === 'rwi'True in AquaRain or when the main app is in the rainwater context.
isFromUwmsactiveWaterContext === 'uwms'True in AquaRecycle or the main app's UWMS context.
isUwmsContextActivewaterContext != nullTrue once the main app has entered a water sub-context.

Rule of thumb: a standalone app gets a fixed context from standaloneAppName (set once at startup); the main app gets a dynamic context via waterContext / setWaterContext as the user navigates. Feature code should read the derived flags (isFromRainwater, isFromUwms, …) rather than compare standaloneAppName directly.

Auth redirect & backend URLs

The other per-environment startup values — the OAuth redirectUri (which auto-resolves to the current origin), the MSAL/Google config, and the API base URL — are documented in Configuration & Security → Auth & backend URLs.


Each app has a navigation helper — a class that maps a server-provided key (like redirectFeature: "PLANT_VIEW") to the correct route. They all live in libs/shared/src/helper/:

AppHelperFile
production / demoNavigationHelper (generic)navHelperInstance.js
uwmsUwmsNavigationHelperuwmsNavigationHelperInstance.js
lakepulseLakePulseNavigationHelperlakePulseNavigationHelperInstance.js
rwiRwiNavigationHelperrwiNavigationHelperInstance.js

Each helper is a singleton. The constructor stores the one instance on a static property, and the static .i getter is how you access it:

class UwmsNavigationHelper {
static instance = null;
constructor() {
if (UwmsNavigationHelper.instance) return UwmsNavigationHelper.instance;
UwmsNavigationHelper.instance = this;
}
static get i() {
if (!UwmsNavigationHelper.instance) new UwmsNavigationHelper();
return UwmsNavigationHelper.instance;
}
}

When the server returns a redirect key, handleNavigationOnResponse looks it up in navHelper.i.routes[...]. If the wrong helper is used, the redirect either 404s or falls back to a generic route — which is exactly why each standalone app must pass its own helper.


bootstrap.jsx vs app.jsx

Two files start every app. Getting the split right is the whole point of the current pattern.

FileShould contain
src/bootstrap.jsxAll startup wiring — login providers (MSAL, Google), theme, the shared store provider with validateLogin, and the router. This is the single source of truth.
src/app/app.jsxJust the routes. A thin shell: useRoutes(appRouter) and nothing else.

src/main.jsx is a one-liner (import('./bootstrap')) — an async boundary left over from the old Module Federation setup. It is kept for consistency but does nothing interesting.


How the pattern evolved

The startup code has been through three stages. Different apps are at different stages today.

Legacy pattern (still in production and demo)

In the original pattern, both bootstrap.jsx and app.jsx wrapped the tree in AppStoreContextProvider. The inner one (in app.jsx) silently won, so the outer one was dead.

// ❌ Legacy app.jsx — declares its own providers
export function App() {
const element = useRoutes(appRouter);
return (
<ThemeProvider theme={CustomTheme}>
<AppStoreContextProvider validateLogin={validateLogin}>
{element}
</AppStoreContextProvider>
</ThemeProvider>
);
}

This works, but it is confusing and leaves no clean place to plug in an app-specific navigation helper.

Current pattern (uwms, lakepulse)

bootstrap.jsx becomes the single source of truth. app.jsx is reduced to just the router.

// ✅ Current bootstrap.jsx
const validateLogin = createValidateLogin(UwmsNavigationHelper); // created once, before React renders

root.render(
<ThemeProvider theme={CustomTheme}>
<AppStoreContextProvider validateLogin={validateLogin} standaloneAppName="uwms">
<RouterProvider router={router}>
<App />
</RouterProvider>
</AppStoreContextProvider>
</ThemeProvider>
);
// ✅ Current app.jsx — no providers, just routes
export function App() {
return useRoutes(appRouter);
}

lakepulse has the cleanest version of this — its app.jsx is literally two lines.

AquaRain (rwi) is half-migrated

AquaRain (rwi) is mid-migration. Its bootstrap.jsx already uses the new pattern (createValidateLogin(RwiNavigationHelper), standaloneAppName="rwi"), but its app.jsx still carries the old provider wiring that should have been removed. The result is a redundant, shadowed inner provider — the same double-nesting as the legacy apps, just with the RWI helper on both.

This is harmless at runtime (both providers use the RWI helper) but it is unfinished work. Trimming rwi's app.jsx down to return useRoutes(appRouter) would complete the migration.

Where each app stands

Appbootstrap.jsxapp.jsxStatus
productionlegacydeclares providers🏷️ Legacy pattern
demolegacydeclares providers🏷️ Legacy pattern
uwmscurrentroutes + a (redundant) ThemeProvider✅ Migrated
lakepulsecurrentroutes only (cleanest)✅ Migrated
rwicurrentstill declares providers⚠️ Half-migrated

Product feature docs: STP / AquaRecycle (uwms) · Lake Pulse (lakepulse) · Rainfall / AquaRain (rwi).


Branding is title-only

It is worth knowing what does not make an app standalone. The theme file (CustomTheme.js), favicon, and stylesheet are byte-for-byte identical across all five apps. The only static per-app branding is the <title> tag in index.html.

All colour and product theming is applied at runtime, driven by standaloneAppName (the active water context) and the account configuration — not baked into each build.


Adding a new standalone app

The high-level steps (the scripts are covered in Deployment & Environments and the existing Commands Reference):

  1. Scaffoldnpm run create:app <name> clones production into apps/<name> and renames everything.
  2. Add a navigation helper — create libs/shared/src/helper/<name>NavigationHelperInstance.js following the singleton pattern above, with the app's own routes table.
  3. Wire bootstrap.jsx — use createValidateLogin(<Name>NavigationHelper) and pass standaloneAppName="<name>" into AppStoreContextProvider. Keep app.jsx as routes-only.
  4. Trim the route table — reduce src/routes/routes.js to just the product's routes, and pass navInstance={<Name>NavigationHelper} to MainLayout.
  5. Set the <title> in index.html.
  6. Add hosting — add Firebase targets in .firebaserc and firebase.json.
Watch out for clone leftovers

npm run create:app copies production wholesale, so a new app starts with libraries and aliases it does not use and a broken NODE_ENV guard in its rspack.config.js (it checks NODE_ENV === '<appname>', which never matches, so production optimizations never turn on). Clean these up — see Legacy & Deprecated.


Next steps