Storing client state
@flyze/lib-core-angular 1.0.0-alpha.34· latestHow to persist a value, send a key path to a different storage location, and write a provider of your own — including the one most apps need: preferences bound to the signed-in user on a shared browser.
FlyzeStorageService is providedIn: 'root' and needs no setup: with no routes registered it is a
guarded localStorage wrapper. Mechanics: features/client-storage.
Store something
Section titled “Store something”Reads and writes are async — always, even against localStorage, so a provider that talks to a
backend can be dropped in later without touching your call sites.
const storage = inject(FlyzeStorageService);
await storage.write('ui-scale', 125);const scale = await storage.read<number>('ui-scale'); // 125, or nullread resolves null for anything it cannot produce — absent, corrupt, storage blocked — and a
failed write resolves rather than rejecting. So the calling pattern is always “use the stored value if
there is one, otherwise the default”, and there is no error path to handle:
{ provide: APP_INITIALIZER, multi: true, useFactory: () => { const storage = inject(FlyzeStorageService); const uiScale = inject(FlyzeUiScaleService);
return async () => { const stored = await storage.read<number>('ui-scale'); if (stored) uiScale.setScale(stored); }; }}An APP_INITIALIZER is early enough for most things: Angular awaits all of them before first render.
It is not early enough to prevent a flash on a slow first paint — for that you need an inline
script in index.html, which cannot use this service at all (it runs before the bundle is parsed, and
the contract is async). Read the prefixed key directly there, and keep the two in sync:
<script> var scale = Number(JSON.parse(localStorage.getItem('fyz.v1.ui-scale') || 'null')); if (scale >= 50 && scale <= 200 && scale !== 100) { document.documentElement.style.fontSize = scale + '%'; }</script>Note the fyz.v1. prefix: the providers add it, so a key you passed as 'ui-scale' is stored as
'fyz.v1.ui-scale', JSON-encoded.
Send a key path somewhere else
Section titled “Send a key path somewhere else”Register a route and every key under that path goes to the provider you name. Each registration contributes one route:
providers: [ { provide: FLYZE_STORAGE_ROUTES, multi: true, useFactory: () => ({ prefix: 'draft', provider: inject(FlyzeSessionStorageProvider) }) }, { provide: FLYZE_STORAGE_ROUTES, multi: true, useFactory: () => ({ prefix: 'cache', provider: inject(FlyzeIndexedDbProvider) }) }]Now storage.write('draft.body', …) dies with the tab, storage.write('cache.products', …) goes to
IndexedDB where there is room for it, and everything else still lands in localStorage. Prefixes
match on segment boundaries, so 'draft' claims 'draft' and 'draft.body' but not
'draftAutosave'. The longest matching prefix wins.
Use FlyzeMemoryStorageProvider on a route to switch persistence off for a path without changing the
code that writes to it — useful for a kiosk build, or in specs.
Write your own provider
Section titled “Write your own provider”Extend FlyzeStorageProvider and implement three methods. Three rules make a provider well-behaved:
- Never throw. Degrade to
nullon read and a no-op on write and delete. Callers are written against a contract that cannot fail. - Keys arrive whole, prefix included — the router does not strip anything. Namespace internally.
- Serialize your own state if several keys share one stored entry, or concurrent writes will read-modify-write over each other.
Scope preferences to the signed-in user
Section titled “Scope preferences to the signed-in user”The problem this solves: localStorage is per-origin, not per-person. If user B signs in on user A’s
machine and inherits de, B may not be able to read the UI well enough to change it back.
Do not namespace keys per user (fyz.v1.user.<userId>.*). It accumulates an entry for everyone
who ever signs in on that machine and never collects them, and what accumulates is a roster of who
has used this device, readable in DevTools long after logout.
Keep the whole section in one entry instead, tagged with a hash of its owner:
{ "owner": "g7d0k2m9x1q", "values": { "language": "de", "color-scheme": "dark" } }Exactly one entry exists no matter how many people use the browser, and what survives a logout is a
single non-reversible hash — no id, no email, no roster. activate(ownerId | null) decides what
happens on each sign-in:
| stored owner | called with | result |
|---|---|---|
| none | an id | nothing to do; the first write creates the entry |
| matches | an id | kept, values readable |
| differs | an id | deleted; the new user starts from defaults |
| any | null (signed out) | kept, reads return null, writes are dropped |
Keeping the entry on logout is deliberate: the same user’s next sign-in restores instantly, and an idle timeout or an expired token clears the session without meaning “forget my preferences”. The accepted cost is that two users alternating on one machine reset each other’s preferences — strictly better than the second one being unable to read the UI.
The implementation
Section titled “The implementation”// FLYZEimport { cyrb53 } from '@flyze/lib-core';import { FlyzeStorageProvider } from '@flyze/lib-core-angular';
interface UserScopedSlot { owner: string; values: Record<string, unknown>;}
export class UserScopedProvider extends FlyzeStorageProvider { readonly #inner: FlyzeStorageProvider; readonly #slotKey: string;
/** cyrb53 of the signed-in user's id, or null - which also disables reads and writes. */ #owner: string | null = null; #queue: Promise<unknown> = Promise.resolve();
constructor(inner: FlyzeStorageProvider, slotKey: string) { super();
this.#inner = inner; this.#slotKey = slotKey; }
public activate(ownerId: string | null): Promise<void> { return this.#enqueue(async () => { this.#owner = ownerId === null ? null : cyrb53(ownerId); if (this.#owner === null) return;
const slot = await this.#readSlot(); if (slot !== null && slot.owner !== this.#owner) { await this.#inner.delete(this.#slotKey); // someone else's - start from defaults } }); }
public read<T = unknown>(key: string): Promise<T | null> { return this.#enqueue(async () => { if (this.#owner === null) return null;
const slot = await this.#readSlot(); if (slot === null || slot.owner !== this.#owner) return null;
return (slot.values[this.#fieldOf(key)] as T) ?? null; }); }
public write(key: string, value: unknown): Promise<void> { return this.#enqueue(async () => { if (this.#owner === null) return;
const slot = await this.#readSlot(); const values = slot?.owner === this.#owner ? slot.values : {}; values[this.#fieldOf(key)] = value;
await this.#inner.write(this.#slotKey, { owner: this.#owner, values }); }); }
public delete(key: string): Promise<void> { return this.#enqueue(async () => { if (this.#owner === null) return;
const slot = await this.#readSlot(); if (slot === null || slot.owner !== this.#owner) return;
delete slot.values[this.#fieldOf(key)];
// The entry survives an empty `values`: the owner tag is what lets the next activate // tell the same user from a different one. await this.#inner.write(this.#slotKey, { owner: this.#owner, values: slot.values }); }); }
/** * One chain, so no two operations read-modify-write the entry at once. Restoring a language and * a colour scheme concurrently would otherwise have the later write drop the earlier value. A * failing operation must not wedge the chain, hence the swallowing tail. */ #enqueue<T>(operation: () => Promise<T>): Promise<T> { const run = this.#queue.then(operation, operation);
this.#queue = run.then( () => undefined, () => undefined );
return run; }
#fieldOf(key: string): string { return key.slice(this.#slotKey.length + 1); }
async #readSlot(): Promise<UserScopedSlot | null> { const stored = await this.#inner.read<UserScopedSlot>(this.#slotKey);
// The values are opaque and a hand-edited entry is a real possibility, so check the shape. return typeof stored?.owner === 'string' && typeof stored?.values === 'object' ? stored : null; }}This is a trimmed copy of an implementation that shipped in this library briefly and was moved out
here, because identity is not a shared UI library’s business. The full version — with misrouted-key
warnings and 16 specs covering the activate table, the queue and the privacy claim — is in this
repo’s history at commit e148ed7, and is worth reading before rewriting it.
Register it
Section titled “Register it”Provide the class once so the initializer and the route can both reach the same instance:
const SECTION_USER = 'fyzUser';export const PREF_LANGUAGE = `${SECTION_USER}.language`;export const PREF_COLOR_SCHEME = `${SECTION_USER}.color-scheme`;
providers: [ { provide: UserScopedProvider, useFactory: () => new UserScopedProvider(inject(FlyzeLocalStorageProvider), SECTION_USER) }, { provide: FLYZE_STORAGE_ROUTES, multi: true, useFactory: () => ({ prefix: SECTION_USER, provider: inject(UserScopedProvider) }) }]Those two key constants are the app’s to own — the library deliberately ships no preference names, so put them somewhere every part of the app can import.
Wire it at boot
Section titled “Wire it at boot”Resolve identity once, activate, then restore and subscribe. The auth library registers its own
APP_INITIALIZER and Angular awaits all initializers before first render, so awaiting the session
costs no extra wall-clock time.
sequenceDiagram autonumber participant NG as Angular bootstrap participant Auth as auth lib initializer participant App as your initializer participant US as UserScopedProvider participant UI as Transloco / FlyzeThemingService
NG->>Auth: APP_INITIALIZER (auth lib) NG->>App: APP_INITIALIZER (app) Auth-->>Auth: restore session from storage App->>Auth: await initialized Auth-->>App: session (or none) App->>US: activate(userInfo.id ?? null) US-->>US: owner mismatch? wipe the entry App->>UI: restore language + colour scheme UI-->>App: applied before first paint App->>UI: subscribe langChanges$ / customPreferredColorScheme$ Note over App,UI: later changes persist through the same routereturn async () => { await auth.initialized; // not firstValueFrom(session$) - see below await userStorage.activate(auth.session?.userInfo?.id ?? null);
const language = await storage.read<string>(PREF_LANGUAGE); if (language) transloco.setActiveLang(language);
const scheme = await storage.read<'dark' | 'light' | 'auto'>(PREF_COLOR_SCHEME); if (scheme) theming.customPreferredColorScheme = scheme;
transloco.langChanges$.subscribe((lang) => storage.write(PREF_LANGUAGE, lang)); theming.customPreferredColorScheme$.subscribe((s) => storage.write(PREF_COLOR_SCHEME, s));};Check the exact member names against lib-oauth-service — but four things are worth knowing before
you write this:
- Gate on the
initializedpromise, notfirstValueFrom(session$). That subject starts fromBehaviorSubject(undefined), sofirstValueFromresolves immediately with no session and you activate as a signed-out user. - Never call
AuthFlowService.isLoggedIn()for this. It side-effects an SSO redirect (projects/lib-auth-service/src/lib/services/flows/auth-flow.service.ts). - A standing
session$subscription covers cross-tab login and logout: callactivateagain on every change. customPreferredColorSchemeis the writable one.preferredColorSchemeanddetectedPreferredColorSchemeare derived and must not be persisted. Restoring a theme name has an ordering hazard the colour scheme does not: the theme setter silently no-ops with a warning for a theme that is not registered yet, so a theme restore has to run aftersetThemeConfig().
Because a signed-out boot activates with null, the login screen reads nothing and writes nothing: it
falls back to the browser language, which is what you want pre-auth anyway.
Two things to check in your app first
Section titled “Two things to check in your app first”- A hardcoded language default. In
flyze-admin-app,app-initializer/language.initializer.tshardcodesconst lang = 'de', and removing it exposes a latent conflict: transloco is configured twice with different defaults (transloco-root.module.tssays'en',app.module.tssays'de'). Align both;'de'preserves today’s effective behavior for a browser that is neither. Thebrick-builder-testharness carries a copy of the same TODO. - What
cyrb53does and does not buy you. It is a 53-bit non-crypto hash: it prevents enumerating who has used a device, but an attacker holding a candidate user id can confirm it. That is acceptable because while a user is signed in the device already stores their id, name and email in plain view in the auth library’sFlyze-Sessionentry — the hash only limits what remains after logout. Do not reach for client-side encryption instead; see the risks in features/client-storage.
Where user preferences are heading
Section titled “Where user preferences are heading”The industry pattern is a hybrid, not an API-only model: sync what defines the user, keep local what defines the device. Outlook keeps its whole settings bag in the user’s mailbox, Notion links appearance to the account while its local cache races the API rather than replacing it, and X makes display settings deliberately per-browser and says so in the UI. None of the three is API-only.
For Flyze the eventual home for the user section is a per-user folder in flyze-storage-api-v3, built
from mechanisms that already exist: an Asset (identity, tree position, owner, ACL) plus AspectData
(the JSON body) — the same pattern dashboards, forms and appcraft apps already use. An asset can be
locked to one user by OwnerKind: User / OwnerId plus a binding
{ Role: User, Identifier: <userId>, Permission: Read|Write|Delete } that does not inherit from its
ancestors.
When it lands it arrives as a provider on the same route, written in the app or in
@flyze/lib-data-store, and may wrap the local provider as its first-paint cache. One registration
changes; no call site does. Two findings have to be resolved first:
POST v1/storage/asset/createperforms no ACL check on the target parent (flyze-storage-api-v3/src/StorageControllerPlugin/Controller/AssetController.cs), so today any user can create assets inside another user’s folder.- The ACL default is permissive: an entity with no binding anywhere up the tree is readable by
everyone (
.../Services/Implementation/ACL/AccessControlService.cs). The per-user binding has to be created at provisioning time, not assumed.