Skip to content

Storing client state

@flyze/lib-core-angular 1.0.0-alpha.34· latest

How 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.

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 null

read 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.

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.

Extend FlyzeStorageProvider and implement three methods. Three rules make a provider well-behaved:

  • Never throw. Degrade to null on 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.

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:

fyz.v1.fyzUser
{ "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 ownercalled withresult
nonean idnothing to do; the first write creates the entry
matchesan idkept, values readable
differsan iddeleted; the new user starts from defaults
anynull (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.

// FLYZE
import { 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.

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.

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 route
return 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 initialized promise, not firstValueFrom(session$). That subject starts from BehaviorSubject(undefined), so firstValueFrom resolves 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: call activate again on every change.
  • customPreferredColorScheme is the writable one. preferredColorScheme and detectedPreferredColorScheme are 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 after setThemeConfig().

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.

  • A hardcoded language default. In flyze-admin-app, app-initializer/language.initializer.ts hardcodes const lang = 'de', and removing it exposes a latent conflict: transloco is configured twice with different defaults (transloco-root.module.ts says 'en', app.module.ts says 'de'). Align both; 'de' preserves today’s effective behavior for a browser that is neither. The brick-builder-test harness carries a copy of the same TODO.
  • What cyrb53 does 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’s Flyze-Session entry — the hash only limits what remains after logout. Do not reach for client-side encryption instead; see the risks in features/client-storage.

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/create performs 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.