Skip to content

fix(ui): store UI states per row so saves are immediate and can't overwrite each other - #3947

Merged
marcelfolaron merged 3 commits into
masterfrom
fix/browser-rpc-session
Oct 18, 2026
Merged

marcelfolaron merged 3 commits into
masterfrom
fix/browser-rpc-session

Conversation

@marcelfolaron

@marcelfolaron marcelfolaron commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Saved UI states showed up one page load late. Close an accordion, reload, and it's open again; reload once more and it's closed. Two states saved at the same moment could also overwrite each other.

Why

  • Writes dropped: UI states are saved over JSON-RPC (Api.Api.setSubmenuState), and JSON-RPC requests don't persist the session. The session copy (usersettings.submenuToggle) was dropped and only the database blob kept the change, so the next page rendered the old value.
  • Lost saves: the database copy was one serialized blob, rewritten from each request's copy. Two saves in flight at the same time could drop each other.
  • Shared key: everything lived under the top-level session key usersettings. The session merge from perf: speed up dashboard & hot paths (session locking, query dedupe, event patterns) #3439 works per top-level key, so parallel writers of different UI states could clobber each other there too.

Fix: UI states leave the session

  • Leantime\Core\UI\UiState: each state is its own zp_settings row (usersetting.{userId}.ui.{name}), written with one atomic upsert(). That's portable across MySQL, Postgres and SQL Server: no read-modify-write, no blob, no lock.
    • Reads: all of the user's states load once per request (scoped binding, one query; the connection is resolved lazily, so pages and tests without a database fall back to defaults).
    • Old saves carry over: the old serialized submenuToggle blob is read as a fallback, and newer rows win.
  • Everything reads and writes through it:
    • Menu::setSubmenuState / getSubmenuState
    • Template::getToggleState (old accordion, subtask arrow, dashboard calendar)
    • the sidebar menu groups and the project selector groups
    • elements.accordion
    • the My Calendar / project calendar saved view
    • Nothing writes usersettings.submenuToggle to the session any more. That also removes getSubmenuState's rewrite of the whole usersettings array into the session on every page view.
  • JSON-RPC stays stateless. StartSession is unchanged; the first version of this PR changed it, and that was reverted after review.

The upcoming layout.content-box (#3948) stores its sidebar widths here too.

Testing

  • UiStateTest (SQLite, 8 tests):
    • A save shows on the next request.
    • Two requests that loaded before either saved both keep their states.
    • Re-saving updates the same row.
    • Legacy blob states still apply and newer rows win.
    • States are per user.
    • Without a user nothing is read or saved.
    • Long names get a short stable key.
    • Without a database, defaults are returned.
  • SubmenuStateTest: a state saved through Menu::setSubmenuState is read back on the next request and nothing goes to the session.
  • AccordionTest: passes with states from UiState.
  • Unit suite: only the 4 known env-only failures (MenuRepositoryTest fails the same way on master).
  • Browser:
    • Closing the Project Hub "My Favorites" section shows it closed on the first reload; reopening shows it open on the first reload.
    • Three setSubmenuState calls fired at the same moment left three rows; none was lost.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KbcnaZTbGANSNAWkGT6HGs

JSON-RPC requests were stateless: leantime.rpc read the user's web session
but its writes were dropped. Every saved UI state (accordion open/closed,
and soon sidebar widths) went to the database only, so the next page
rendered the old value from the session and was right only one load later.

- StartSession persists browser RPC requests (AJAX to /api/jsonrpc with
  the session cookie) like web requests: lock-free, changed keys merged on
  save. API clients without a session cookie and cron stay stateless.
- Menu::setSubmenuState writes on top of the stored states instead of the
  request's session copy, so a state saved by another request in the
  meantime isn't dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbcnaZTbGANSNAWkGT6HGs
@marcelfolaron
marcelfolaron requested a review from a team as a code owner October 18, 2026 15:25
@marcelfolaron
marcelfolaron requested review from broskees and a balanced review from Copilot and removed request for a team October 18, 2026 15:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Overlapping requests can still lose database preference updates and overwrite newer session state.

2 open issues
What changed in this PR

Fixes delayed UI-state restoration by persisting the app’s browser JSON-RPC session changes.

Changes:

  • Enables session persistence for AJAX JSON-RPC requests carrying a session cookie.
  • Merges submenu saves with stored preferences.
  • Adds session-classification and preference-preservation tests.
File Description
tests/​Unit/​app/​Domain/​Menu/​Repositories/​SubmenuStateTest.php Tests preservation of stored preferences and session fallback.
tests/​Unit/​app/​Core/​Middleware/​BrowserRpcSessionTest.php Tests which requests persist sessions.
app/​Domain/​Menu/​Repositories/​Menu.php Reads stored preferences before saving submenu state.
app/​Core/​Middleware/​StartSession.php Enables persistence for browser RPC sessions.

Review effort: Balanced

Comment thread app/Core/Middleware/StartSession.php Outdated
Comment thread app/Domain/Menu/Repositories/Menu.php Outdated
Rework of the first approach after review: persisting browser JSON-RPC
sessions joined more writers to a session merge that only works per
top-level key (all UI states share `usersettings`), and the serialized
submenuToggle blob was still a read-modify-write. Both could still drop a
state saved at the same time.

Instead, UI states leave the session:
- Leantime\Core\UI\UiState stores each state as its own zp_settings row
  ("usersetting.{id}.ui.{name}") with one atomic upsert, and reads all of
  the user's states once per request (scoped binding, lazy connection).
  The old serialized submenuToggle blob is read as a fallback, so saved
  choices carry over.
- Menu::setSubmenuState / getSubmenuState, Template::getToggleState, the
  sidebar menu, the project selector groups, elements.accordion and both
  calendars' saved view read and write through it; nothing writes
  usersettings.submenuToggle to the session any more.
- StartSession is back to the original: JSON-RPC requests don't persist
  the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbcnaZTbGANSNAWkGT6HGs
Copilot AI balanced review requested due to automatic review settings October 18, 2026 15:56
@marcelfolaron marcelfolaron changed the title fix(session): persist session changes from the app's own JSON-RPC calls fix(ui): store UI states per row so saves are immediate and can't overwrite each other Oct 18, 2026
marcelfolaron added a commit that referenced this pull request Oct 18, 2026
The content box now reads saved sidebar widths from UiState (one row per
state), the store #3947 introduces, instead of the session copy, which
JSON-RPC saves never updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbcnaZTbGANSNAWkGT6HGs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Legacy dotted-name preferences are lost, and case-insensitive database keys allow distinct state names to overwrite each other.

2 open issues
2 resolved since last review

Review effort: Balanced

Comment thread app/Core/UI/UiState.php Outdated
Comment thread app/Core/UI/UiState.php Outdated
From review:
- zp_settings.key compares case-insensitively on MySQL, so "Foo" and
  "foo" would have shared one row. Stored names now escape everything
  outside [a-z0-9_-] ("~" + hex), so distinct names stay distinct under
  any collation.
- The old store wrote through a dot-notation session path, so a name with
  dots was saved nested in the serialized blob. The fallback flattens it
  back (Arr::dot), so those choices carry over too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbcnaZTbGANSNAWkGT6HGs
Copilot AI balanced review requested due to automatic review settings October 18, 2026 16:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No unresolved blocking issues remain in the storage implementation, migrated consumers, or regression coverage; tests were not run locally.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

@marcelfolaron
marcelfolaron merged commit 43a4cf9 into master Oct 18, 2026
13 checks passed
@marcelfolaron
marcelfolaron deleted the fix/browser-rpc-session branch October 18, 2026 16:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants