Skip to content

Perf(UserSettings): caching makes it 10x faster#3481

Draft
VariableVince wants to merge 5 commits intomainfrom
settings-cache
Draft

Perf(UserSettings): caching makes it 10x faster#3481
VariableVince wants to merge 5 commits intomainfrom
settings-cache

Conversation

@VariableVince
Copy link
Copy Markdown
Contributor

@VariableVince VariableVince commented Mar 21, 2026

Description:

Skip slow and blocking LocalStorage reads, replace by a Map.

  • No out-of-sync issue between main and worker thread: Earlier PRs got a comment from evan about main & worker.worker thread having their own version of usersettings and possibly getting out-of-sync (see Cache local storage reads to improve performance #760 (review), Cache User Settings  #896 (review) and Add dark mode change event #1266.
    But userSettings is not used in files ran by worker.worker, not even 10 months after evan's first comment about it. In GameRunner, createGameRunner sends NULL to getConfig as argument for userSettings. And DefaultConfig guards against userSettings being null by throwing an error, but it has never been thrown which points to worker.worker thread not using userSettings. So we do not need to worry about syncing between the threads currently.
    (If needed in the future after all, we could quite easily sync it, by loading the userSettings cache on worker.worker and listening to the "user-settings-changed" event @scamiv to keep it synced (changes in WorkerMessages and WorkerClient etc would be needed to handle this).

  • Went with cache in UserSettings, not with listening to "user-settings-changed" event: "user-settings-changed" was added by @scamiv and is used in PerformanceOverlay. Which is great for single files that need the very best performance. But having to add that same system to any file reading settings, scales poorly and would lead to messy code. Also, a developer could make the mistake of not listening to the event and it would end up just reading LocalStorage again just like now. Also a developer might forget removing the listener or so etc. The cache is a central solution and fast, without changes to other files needed and future-proof.

  • Make sure each setting is cached: UserSettingsModal was using LocalStorage directly by itself for some things. Made it use the central UserSettings methods instead so we avoid LocalStorage reads as much as possible. For this, changed get() and set() in UserSettings to getBool() and setBool(), to introduce a getString() and setString() for use in UserSettingsModal while keeping getCached() and setCached() private within UserSettings.

  • Remove unused 'focusLocked' and 'toggleFocusLocked' from UserSettings: was last changed 11 months ago to just return false. Since then we've moved to different ways of highlighting and this setting isn't used anymore. No existing references or callers are left.

  • Other files:
    -- Have callers call the renamed functions (see point above)
    -- Remove userSettings from UILayer and Territorylayer: the variable is unused in those files. Also remove from GameRenderer when it calls TerritoryLayer.
    -- Cache calls to defaultconfig Theme (which in turn calls dark mode setting)/Config better in: GameView and Terrainlayer.

BEFORE
image

AFTER first tests (need to do more)
image

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

Please put your Discord username so you can be contacted if a bug or regression is found:

tryout33

…ache Theme/Config better in two files (Theme in DefaultConfig in turn calls dark mode setting, so it's related to the changes)
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 21, 2026

Walkthrough

Replaced direct localStorage access with a typed, cached UserSettings API (getBool/getString/getFloat/set*); removed UserSettings dependency from some layers; cached game config/theme in rendering layers; updated UI bindings (keybinds, booleans, attack ratio) to use the new typed accessors.

Changes

Cohort / File(s) Summary
User Settings Core
src/core/game/UserSettings.ts
Added static in-memory cache and helpers (getCached/setCached/removeCached); introduced typed accessors getBool/setBool, getString/setString, getFloat/setFloat; expanded emitChange types; removed focusLocked APIs.
Settings UI / Modal
src/client/UserSettingModal.ts
Switched keybind, boolean toggles, and attack-ratio persistence from direct localStorage to this.userSettings.getString/getBool/getFloat and corresponding set* calls; attack-ratio now uses getFloat(..., 0.2) * 100.
Renderer wiring
src/client/graphics/GameRenderer.ts
Omitted userSettings when constructing TerritoryLayer (new TerritoryLayer(...) now takes one fewer argument).
Territory & UI Layers
src/client/graphics/layers/TerritoryLayer.ts, src/client/graphics/layers/UILayer.ts
Removed UserSettings import/field/constructor param from TerritoryLayer; removed UserSettings instantiation/import from UILayer.
TerrainLayer & GameView
src/client/graphics/layers/TerrainLayer.ts, src/core/game/GameView.ts
Cached Config/theme() in constructors and replaced repeated game.config().theme() calls with the cached reference.
Copy Button
src/client/components/CopyButton.ts
Switched lobby ID visibility read to UserSettings.getBool("settings.lobbyIdVisibility", true) (typed boolean).
Performance Overlay
src/client/graphics/layers/PerformanceOverlay.ts
Replaced generic set persistence with setBool("settings.performanceOverlay", ...) in toggle and close handlers.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🗝️ A cached map hums where keys once slept,
Typed whispers now the settings kept,
Layers lighter, theme held near,
Sliders, toggles — tidy and clear,
Small refactors, a cleaner rep.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main optimization: implementing caching to improve UserSettings performance significantly.
Description check ✅ Passed The pull request description clearly relates to the changeset, detailing performance improvements through caching, removal of unused code, and refactoring of the UserSettings class and its callers.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/core/game/UserSettings.ts`:
- Around line 23-28: The getCached method currently uses the || operator which
treats empty strings as falsy and converts them to null; update getCached in
UserSettings to use the nullish coalescing operator (??) when returning the
cached value so that empty strings are preserved (i.e., return
UserSettings.cache.get(key) ?? null) and ensure the cache set remains
localStorage.getItem(key) so null is only used for missing keys; reference the
getCached method and UserSettings.cache when making this change.
- Line 7: Replace all generic userSettings.get(...) and userSettings.set(...)
call sites with the typed API provided by UserSettings: use
userSettings.getBool(key, defaultValue) and userSettings.setBool(key, value) for
boolean settings; use userSettings.getString/setString for string values and
userSettings.getFloat/setFloat for numeric values. Specifically update the call
in CopyButton (was userSettings.get("settings.lobbyIdVisibility", true)) to
userSettings.getBool("settings.lobbyIdVisibility", true) and the calls in
PerformanceOverlay (was userSettings.set("settings.performanceOverlay",
nextVisible)) to userSettings.setBool("settings.performanceOverlay",
nextVisible); audit other usages and migrate any remaining generic get/set to
the appropriate typed method (getBool/setBool, getString/setString,
getFloat/setFloat).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7d12040b-0ed7-4571-bdea-48f6f1e3430a

📥 Commits

Reviewing files that changed from the base of the PR and between 13df5cf and 4e4304b.

📒 Files selected for processing (7)
  • src/client/UserSettingModal.ts
  • src/client/graphics/GameRenderer.ts
  • src/client/graphics/layers/TerrainLayer.ts
  • src/client/graphics/layers/TerritoryLayer.ts
  • src/client/graphics/layers/UILayer.ts
  • src/core/game/GameView.ts
  • src/core/game/UserSettings.ts
💤 Files with no reviewable changes (2)
  • src/client/graphics/layers/TerritoryLayer.ts
  • src/client/graphics/layers/UILayer.ts

@github-project-automation github-project-automation bot moved this from Triage to Development in OpenFront Release Management Mar 21, 2026
coderabbitai[bot]
coderabbitai bot previously approved these changes Mar 21, 2026
@openfrontio openfrontio bot had a problem deploying to staging March 21, 2026 01:01 Failure
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/core/game/UserSettings.ts (1)

40-50: ⚠️ Potential issue | 🔴 Critical

Finish migrating the attacking troops overlay setting.

Line 119 and Line 123 still call this.get() / this.set(), but this class now exposes only the typed API. That breaks src/client/graphics/layers/SettingsModal.ts and src/client/graphics/layers/AttackingTroopsOverlay.ts, and CI is already failing on TS2339.

🔧 Minimal fix
   attackingTroopsOverlay() {
-    return this.get("settings.attackingTroopsOverlay", true);
+    return this.getBool("settings.attackingTroopsOverlay", true);
   }

   toggleAttackingTroopsOverlay() {
-    this.set("settings.attackingTroopsOverlay", !this.attackingTroopsOverlay());
+    this.setBool(
+      "settings.attackingTroopsOverlay",
+      !this.attackingTroopsOverlay(),
+    );
   }

Also applies to: 118-123

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/game/UserSettings.ts` around lines 40 - 50, The code still uses the
removed untyped methods this.get() / this.set() for the attacking troops overlay
setting; update callers to use the new typed API such as getBool(key,
defaultValue) and setBool(key, value). Locate the places that call
this.get("...") or this.set("...", ...) (notably in SettingsModal.ts and
AttackingTroopsOverlay.ts) and replace them with calls to
UserSettings.getBool(key, defaultValue) and UserSettings.setBool(key,
booleanValue) (or the appropriate getX/setX typed helpers) so they use
getBool/setBool and preserve default values and emitChange behavior via setBool.
🧹 Nitpick comments (1)
src/core/game/UserSettings.ts (1)

7-38: Verify cache sync outside the current tab.

After the first read, UserSettings.cache keeps the value for the rest of the tab. If another tab or window changes localStorage, reads like darkMode() or performanceOverlay() will stay stale until reload. Please verify this with a simple two-tab test before merging; if multi-tab use matters, this needs one storage listener to update or clear the static cache.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/game/UserSettings.ts` around lines 7 - 38, UserSettings.cache is
never updated when other tabs change localStorage, so
getCached()/darkMode()/performanceOverlay() can become stale; add a window
"storage" event listener (registered once, e.g., in a static initializer or
class bootstrap) that checks event.key and updates UserSettings.cache: if
event.newValue is null set cache entry to null (matching removeCached behavior),
otherwise set the cache to event.newValue; ensure the listener only touches keys
managed by UserSettings and does not throw, and keep existing
setCached/removeCached to continue writing to localStorage and emitting events
via emitChange; verify with a two-tab test that changing a setting in one tab
updates the other tab’s cached value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/core/game/UserSettings.ts`:
- Around line 40-50: The code still uses the removed untyped methods this.get()
/ this.set() for the attacking troops overlay setting; update callers to use the
new typed API such as getBool(key, defaultValue) and setBool(key, value). Locate
the places that call this.get("...") or this.set("...", ...) (notably in
SettingsModal.ts and AttackingTroopsOverlay.ts) and replace them with calls to
UserSettings.getBool(key, defaultValue) and UserSettings.setBool(key,
booleanValue) (or the appropriate getX/setX typed helpers) so they use
getBool/setBool and preserve default values and emitChange behavior via setBool.

---

Nitpick comments:
In `@src/core/game/UserSettings.ts`:
- Around line 7-38: UserSettings.cache is never updated when other tabs change
localStorage, so getCached()/darkMode()/performanceOverlay() can become stale;
add a window "storage" event listener (registered once, e.g., in a static
initializer or class bootstrap) that checks event.key and updates
UserSettings.cache: if event.newValue is null set cache entry to null (matching
removeCached behavior), otherwise set the cache to event.newValue; ensure the
listener only touches keys managed by UserSettings and does not throw, and keep
existing setCached/removeCached to continue writing to localStorage and emitting
events via emitChange; verify with a two-tab test that changing a setting in one
tab updates the other tab’s cached value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6aefa97a-c104-4c68-98f8-c27eed41f7b1

📥 Commits

Reviewing files that changed from the base of the PR and between e9d8378 and a58a076.

📒 Files selected for processing (4)
  • src/client/UserSettingModal.ts
  • src/client/graphics/GameRenderer.ts
  • src/core/game/GameView.ts
  • src/core/game/UserSettings.ts
✅ Files skipped from review due to trivial changes (1)
  • src/core/game/GameView.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/client/UserSettingModal.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/core/game/UserSettings.ts (1)

7-37: ⚠️ Potential issue | 🟠 Major

Keep the shared cache in sync with external localStorage updates.

After the first read, getCached() returns the memoized value forever. If a setting changes in another tab/window, through DevTools, or through any remaining direct localStorage write, every new UserSettings instance in this tab will keep seeing stale data until reload. Please update or clear the cache from the browser storage event at minimum.

Possible fix
 export class UserSettings {
   private static cache = new Map<string, string | null>();
+  private static storageSyncAttached = false;
+
+  constructor() {
+    UserSettings.attachStorageSync();
+  }
+
+  private static attachStorageSync(): void {
+    if (UserSettings.storageSyncAttached) return;
+    if (typeof globalThis.addEventListener !== "function") return;
+
+    globalThis.addEventListener("storage", (event: StorageEvent) => {
+      if (event.storageArea !== localStorage) return;
+
+      if (event.key === null) {
+        UserSettings.cache.clear();
+        return;
+      }
+
+      UserSettings.cache.set(event.key, event.newValue);
+    });
+
+    UserSettings.storageSyncAttached = true;
+  }

Based on learnings, new UserSettings() is the established pattern in this repo, so this static cache is shared across many component-local instances.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/game/UserSettings.ts` around lines 7 - 37, The static cache
(UserSettings.cache) isn't updated when other tabs or scripts change
localStorage; add a window "storage" event handler in the UserSettings class
(e.g., in a static initializer or constructor) that listens for storage events,
and when event.key matches a cached key update UserSettings.cache to
event.newValue (or set to null if newValue is null) so getCached() returns fresh
values; ensure existing setters (setCached/removeCached) continue to write
localStorage and update the cache and that the storage handler ignores events
with key === null or from the same window if needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/core/game/UserSettings.ts`:
- Around line 7-37: The static cache (UserSettings.cache) isn't updated when
other tabs or scripts change localStorage; add a window "storage" event handler
in the UserSettings class (e.g., in a static initializer or constructor) that
listens for storage events, and when event.key matches a cached key update
UserSettings.cache to event.newValue (or set to null if newValue is null) so
getCached() returns fresh values; ensure existing setters
(setCached/removeCached) continue to write localStorage and update the cache and
that the storage handler ignores events with key === null or from the same
window if needed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2391ec50-b44c-4046-a051-022855ff4e55

📥 Commits

Reviewing files that changed from the base of the PR and between a58a076 and e2ae7ec.

📒 Files selected for processing (1)
  • src/core/game/UserSettings.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Performance Performance optimization

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

1 participant