A user's arrangement of the web app belongs to the user account, not to one browser. The client module webapp/js/ui-state.js reads and writes named values through two routes of the auth module, and the server stores them per user in the table auth_user_ui_state. This page explains the data model, the routes, the write coalescing, the conflict rule for the home layout, and which preferences are not stored on the server.
In short¶
- One row holds one key and one JSON value for one user; the key is unique among live rows of that user (
src/cluster_erp/modules/auth/ui_state/models.py:13-35). GET /api/v1/auth/ui-statereturns every key of the signed-in user;PUT /api/v1/auth/ui-state/{key}stores one value (src/cluster_erp/modules/auth/ui_state/routes.py:21-39).- Both routes take the user id from the session, never from the request, and require authentication only (
src/cluster_erp/modules/auth/ui_state/routes.py:15-18). - A stored value may be at most 262,144 bytes as JSON (
src/cluster_erp/modules/auth/ui_state/schemas.py:8-29). - The client batches writes for 600 ms and flushes pending writes when the page is hidden or closed (
webapp/js/ui-state.js:41-73).
How it works¶
Server side¶
| Part | Content | Source |
|---|---|---|
Model UserUiState |
Table auth_user_ui_state; user_id (foreign key to auth_users.id), key (up to 60 characters), value (JSONB), plus the base columns |
src/cluster_erp/modules/auth/ui_state/models.py:13-35 |
| Unique index | uq_auth_user_ui_state_user_id_key_live on user_id and key where deleted_at IS NULL |
src/cluster_erp/modules/auth/ui_state/models.py:24-29 |
Schema UiStateWrite |
value of any JSON shape, rejected when not serialisable or larger than _MAX_UI_STATE_BYTES |
src/cluster_erp/modules/auth/ui_state/schemas.py:10-29 |
Service UiStateService.all_for |
Returns {key: value} for live rows of one user |
src/cluster_erp/modules/auth/ui_state/services.py:19-28 |
Service UiStateService.put |
Inserts a row or updates the live row's value | src/cluster_erp/modules/auth/ui_state/services.py:30-44 |
| Route registration | Included in the auth module router under /api/v1/auth |
src/cluster_erp/modules/auth/module.py:55, src/cluster_erp/modules/auth/module.py:68, src/cluster_erp/core/module_base.py:37-38 |
The base columns and soft delete come from the shared base model; see base model and ids. An update of a value is recorded by the automatic change history like any other mapped entity; see change history.
Client side¶
loadUiState() reads all keys once per session and shares one request among early callers. A failed read returns null and leaves the local copy in use (webapp/js/ui-state.js:18-39). saveUiState(key, value) updates the in-memory copy, queues the value, and restarts a 600 ms timer; the flush sends one PUT per key with keepalive so that a write survives a page reload (webapp/js/ui-state.js:41-73). visibilitychange to hidden and pagehide flush the queue at once (webapp/js/ui-state.js:58-65). Logout drops the cache and the queue (webapp/js/ui-state.js:75-85).
Keys written by the web app¶
| Key | Content | Writer | Source |
|---|---|---|---|
springboard |
PC home layout {pages, updatedAt} |
save |
webapp/js/home.js:609-616, webapp/js/device-class.js:23 |
springboard_tablet |
Tablet home layout | save |
webapp/js/device-class.js:24 |
springboard_phone |
Phone home layout | save |
webapp/js/device-class.js:25 |
dock_pins |
Ordered list of pinned app ids | setDockPins |
webapp/js/home.js:1004-1008 |
dock_switcher |
Whether the floating button lives in the dock | setSwitcherDocked |
webapp/js/home.js:1033-1038 |
launcher |
Dock edge and auto-hide flag | applyAutohide, setEdge |
webapp/js/app-wheel.js:158-161, webapp/js/app-wheel.js:563-567 |
density |
Row density tier | setDensity |
webapp/js/prefs.js:90-99 |
spot_recent |
Up to 8 recent search picks | rememberRecent |
webapp/js/home.js:1135-1157 |
Widget settings such as the Mine or All scope are stored inside the layout value, on the widget item's cfg (webapp/js/widget-scope.js:4-8).
Home layout conflict rule¶
The home layout is cached in local storage and stored on the server. hydrateFromServer() decides which copy wins (webapp/js/home.js:4565-4631):
- Dock pins, recent searches, density, and the docked button are taken from the server as they are (
webapp/js/home.js:4579-4589). - If the server has no layout for this device class, a first-time class starts from the PC layout; otherwise the local copy is published to the server (
webapp/js/home.js:4590-4615). - If both exist, each carries an
updatedAtstamp. The server copy replaces the local one only when it is strictly newer; a newer local copy is sent to the server (webapp/js/home.js:4616-4631).
A failed request changes nothing; the local arrangement is never discarded because the server could not be reached (webapp/js/home.js:4565-4570).
Preferences kept in the browser only¶
| Preference | Storage | Key | Source |
|---|---|---|---|
| Theme | local storage | cluster_theme |
webapp/js/prefs.js:5, webapp/js/prefs.js:61-65 |
| Time zone | local storage | cluster_tz |
webapp/js/prefs.js:6, webapp/js/prefs.js:111-114 |
| Language | local storage | cluster_lang |
webapp/js/i18n.js:9, webapp/js/i18n.js:80 |
| Accessibility settings | local storage | cluster_a11y |
webapp/js/a11y.js:5, webapp/js/a11y.js:44-46 |
| Floating button position | local storage | cluster_float_btn |
webapp/js/home.js:2936, webapp/js/home.js:2976-2979 |
| Open apps after reload | session storage | cluster.session.open_apps |
webapp/js/app-session.js:17 |
| Unsaved form drafts | memory and session storage | cluster.session.form_drafts |
webapp/js/form-drafts.js:18 |
Why it is built this way¶
- The comment in
webapp/js/ui-state.js:1-9states that a layout kept only in local storage differed per browser, device, and host name, and that a cleared cache looked like a reset. - The model docstring states that only the owning user, or an administrator acting for them, can change a row (
src/cluster_erp/modules/auth/ui_state/models.py:14-21). - The schema comment states that the size cap stops a user from storing arbitrary amounts of data in the per-user slot (
src/cluster_erp/modules/auth/ui_state/schemas.py:8-9).
Rules and boundaries¶
- The project's written engineering rules require browser test suites to use
web-client/_ui_state_guard.mjs. The guard runs suites under a dedicated test account so that a suite never changes a real person's layout (web-client/_ui_state_guard.mjs:1-4). See e2e battery. - Browser tests:
web-client/_e2e_dock_pins.mjs,web-client/_e2e_app_wheel.mjs,web-client/_e2e_density.mjs,web-client/_e2e_mobile_home.mjs,web-client/_e2e_command_palette.mjs.
Interaction with other parts¶
- The decision record is server-side UI state.
- Authentication of the two routes is described on authentication and sessions.
Limits and open points¶
- Keys are free strings chosen by the client, between 1 and 60 characters; a longer key is rejected with HTTP 422 before the handler runs (
src/cluster_erp/modules/auth/ui_state/models.py:34,src/cluster_erp/modules/auth/ui_state/routes.py:33). - No route deletes a key. Not determined from code: whether keys of retired features are ever removed.
Terms introduced¶
| Term | Code identifier | Definition | Turkish term | Source |
|---|---|---|---|---|
| UI state | UserUiState |
A named JSON value that stores part of one user's interface arrangement on the server. | kullanıcı arayüz durumu | src/cluster_erp/modules/auth/ui_state/models.py:13 |
| layout stamp | updatedAt |
The time stamp that decides whether the server or the browser copy of the home layout wins. | düzen damgası | webapp/js/home.js:612 |
Related pages¶
Verified against commit 92f8051 on 2026-11-13.