Caching and browser persistence

Why this page exists

State survives page reloads in six different places, written by reducers, services and interceptors. "Why does it still show the old value" and "why did my override disappear" are almost always answered here.

localStorage

Key Written by Contents and lifetime

currentUser

authReducers (setPersistedState on LOGIN); also read directly by EvaComponent and UsersService

The merged user record including the JWT. Its presence is what makes the app consider you logged in — isAuthenticated is derived from it at reducer-init time. Cleared by localStorage.clear() on logout.

suppliers_last_query

supplierReducers (setPersistedQuery on SET_SUPPLIERS_QUERY_STRING)

The last supplier-list query string. When absent the default is created_since:<today − 1 day>, which is why a fresh session shows only recently created suppliers.

layoutState

layoutReducers

Persisted layout preferences — CRM/supplier-info panel states per route pattern.

lscache-utilsState (plus an lscache expiry key)

utilsReducers via lscache.set(UTILS_STATE_KEY, …, 24 * 60)

Reference data — timezones, funding periods, US states, countries. 24-hour TTL.

ng-cache-<md5>

HttpCacheLocalStorage

Cached GET responses, keyed by MD5(JSON.stringify(request)), valid for 5 minutes. Only written if something invokes the cache — CachingInterceptor is not registered, so in practice this is dormant. See below.

AuthEffects.clearStorages() is localStorage.clear() — it wipes everything, not just the session. Logging out therefore also discards the persisted supplier query, the layout preferences and the reference-data cache. That is why the first page after a login is slower and the supplier list resets to the default query.

sessionStorage

Per tab, so two tabs can legitimately disagree.

Key Written by Contents

loginRedirectUrl

RouteAccessResolver.handleLogout()

The URL to return to after login, with /#/ stripped. Read by AuthEffects.login. Never cleared after use — see authentication.

FeatureExecFlags

FeatureFlagsService.storeAvailableFlagsExecStatus

The resolved flag set for this application as [{ FLAG: boolean }, …].

FeatureExecFlagsLastChange

Same

Timestamp of the last override change. Compared against the global setting’s lastChange to decide whether session overrides still apply.

FeatureExecFlagsEva

FeatureFlagsEvaService

EVA’s resolved flag set — deliberately separate keys so the two applications' overrides never collide.

FeatureExecFlagsLastChangeEva

Same

EVA’s override timestamp.

Flag keys are cleared by clearAvailableFlagsExecStatus(), which LoginComponent calls on entry. See feature flags.

Cookies

One cookie, written through shared/services/cookie-service/cookie-service.ts (a thin wrapper over ngx-cookie-service):

Cookie Detail

PYBEvaToken

The JWT, set by EvaService.passTokenToEva() on environment.domain (the shared parent domain) with path: '/' and expires: 1 day. Deleted and rewritten on each set. EvaComponent clears it on window:unload / beforeunload. Not written on localhost — there the token goes in the iframe URL instead. See iframe messaging.

CookieService also exposes getAllCookies() and deleteAllCookies(); neither is used in the current code.

The HTTP response cache

HttpCacheAbstract → HttpCacheLocalStorage, bound in AppModule:

private readonly localStorageCacheKeyPrefix = 'ng-cache-';
private readonly validCacheTimespan = 5 * 60 * 1000;    // 5 minutes

get(req)  → JSON.parse(localStorage[key]); if older than 5 min, removeItem and return null
put(req, resp) → localStorage[key] = { data: resp, timestamp: Date.now() }
key    → 'ng-cache-' + Md5.hashAsciiStr(JSON.stringify(req))

Every method is wrapped in try/catch returning null or doing nothing, so a quota error or corrupt entry degrades silently.

CachingInterceptor implements read-through caching for GET`s against this cache — and is not registered in `AppModule’s `HTTP_INTERCEPTORS. It is dormant.

If you enable CachingInterceptor, note that:

  • It caches all `GET`s, including permission- and supplier-scoped responses, keyed only by the serialised request. Two users on the same machine would share entries.

  • It `cloneDeep`s the request before caching because "the request is modified internally during next steps" — i.e. the interceptor chain mutates it.

  • The 5-minute TTL applies uniformly; there is no per-endpoint control.

  • Responses are stored as whole HttpResponse objects in localStorage, so large payloads can exhaust quota.

Domain caching in the store

The application’s real caching strategy is in-state, not HTTP-level.

Cache Behaviour

supplier.suppliers[supplierKey]

Every supplier visited this session. SupplierEffects.getSupplierDetails returns the cached aggregate unless payload.refresh is set. A refresh also appends refreshTimestamp=Date.now() to the request. Stale by design — nothing invalidates it. See suppliers.

crm.crmStatesList[targetUuid]

Per-target CRM timeline state, so switching between suppliers does not re-fetch.

utils slice

Reference data, additionally persisted with a 24-hour TTL.

globalSettings slice

The settings collection; GlobalSettingsService.getAll(force) re-fetches only when forced.

marketplace.possiblePaymentGateways

The gateway list.

Effect-level of() short-circuits

Nearly every fetch effect checks state first and returns an empty observable on a hit.

Several supplier reducer cases reset the slice, which silently drops these caches:

  • FETCH_SUPPLIER_DETAILS returns { …​initialState, suppliers, lastQueryString } — clearing the part-edit maps and all progress flags.

  • ADD_PAYMENT_GATEWAY returns { …​initialState, supplierModel } — discarding the whole suppliers cache and lastQueryString.

  • UNLOAD_SUPPLIER keeps suppliers and lastQueryString and resets the rest.

In-memory service caches

Easy to miss because they are neither store nor storage.

Cache Behaviour and hazard

UsersService.usersSubject / groupsSubject / usersAndGroupsSubject

BehaviorSubject`s. `getPreparedCombinedList() fetches users and groups once and caches for the session — with no invalidation. A user created in this session will not appear in an assignee picker until reload.

UsersService.subPermsArr

Caches perms/:permCode/sub-perms — ignoring permCode. A second call for a different permission returns the first one’s results. Only one caller exists today. See authorisation.

FeatureFlagsService.globalSettingsFetchedFlags

BehaviorSubject keyed by setting key; cleared by clearGlobalSettingsFetchedFlags().

FeatureFlagsService.lastUpdateFFfromGlobalSettings

A Record<string, number> of lastChange timestamps per setting key.

PendingOfferRiskRatingService.defaultProgressiveAdvanceSettings

Memoises the pricing-defaults fixture. See advances.

B2bWorkflowService.streamShared$

The shared SSE stream — never reset, so a dead stream is never replaced. See real-time streams.

LookupRepository / ConfigsService

Backed by the utils and globalSettings slices respectively rather than their own caches.

What clears what

Action Clears

Logout (LOGOUT or LOGIN_FAILED)

All of localStorage — session, supplier query, layout, lscache reference data. Also EvaComponent clears PYBEvaToken on unload.

Visiting /login

FeatureExecFlags* (both applications) via clearAvailableFlagsExecStatus() and FeatureFlagsEvaService.reset().

Editing flags in DEV Admin → save to global settings

Effectively invalidates every user’s session overrides, because the new lastChange is newer than their stored timestamp.

Page reload

All in-memory service caches and the whole store, except what is rehydrated from storage (auth, utils, supplier.lastQueryString, layout).

New tab

sessionStorage starts empty — flags re-resolve from build + global settings.

24 hours

lscache reference data expires.

5 minutes

ng-cache-* entries expire (when the cache is used at all).

Security notes

Item Consideration

JWT in localStorage

Readable by any script in the page. Accepted for an internal tool on a controlled network; do not extend the pattern.

JWT in a cookie on the parent domain

Necessary for the EVA seam. secure / sameSite are supported by CookieService but not passed by EvaService.

JWT in the iframe URL on localhost

Appears in browser history. Local development only.

Request ring buffer in state

The errors slice holds the last 50 serialised requests, including bodies, and is visible in Redux DevTools — which is instrumented in production. It is also included in `ErrorLogger’s store dump. See error handling.

Supplier PII in state

supplier.suppliers holds tax ids, owner SSNs and addresses for every supplier visited this session.

Practical guidance

  • Reading persisted state: go through the store, not localStorage, except where existing code already does otherwise (EvaComponent, UsersService).

  • Adding a storage key: define the key as a const next to its writer, and add it to this page.

  • Needing fresh domain data: dispatch the fetch action with refresh: true rather than clearing storage.

  • Debugging "stale data": check the store cache first (supplier.suppliers[key]), then the service-level `BehaviorSubject`s, then storage.

  • Do not rely on localStorage.clear() semantics — if you add a key that must survive logout, logout will delete it.