Skip to content

fix: infinite preference sync feedback loop and default item page size - #1632

Draft
IceeAn wants to merge 2 commits into
sysadminsmedia:mainfrom
IceeAn:fix/preference-fixes
Draft

fix: infinite preference sync feedback loop and default item page size#1632
IceeAn wants to merge 2 commits into
sysadminsmedia:mainfrom
IceeAn:fix/preference-fixes

Conversation

@IceeAn

@IceeAn IceeAn commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

What type of PR is this?

  • bug
  • cleanup

What this PR does / why we need it:

This PR fixes the infinite loop issue involving requests to v1/users/self/settings, which occurs when settings are modified under poor network conditions (when the request takes longer than 1s). It can be reproduced by setting the network throttling to 3G in Chrome DevTools.

It also sets default item page size to 12. The default page size is 10 but the available options are 12/24/48/96. Also, setting the page size to 10 leaves empty space at the end when each row displays 3 or 4 cards.

  • frontend/composables/use-preferences.ts:
    • Changes the default item page size from 10 to 12.
    • Uses a synchronous watcher so server snapshots are not treated as local changes, preventing an infinite preference sync feedback loop.

Which issue(s) this PR fixes:

Fixes a bug related to #1617

Special notes for your reviewer:

This PR fixes another bug mentioned in #1617 , but not the main one.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Summary by CodeRabbit

  • New Features

    • Increased the default number of items displayed per table page from 10 to 12.
  • Bug Fixes

    • Improved reliability of saving view preferences, including safer retry handling during temporary failures.
    • Enhanced server/client preference reconciliation to better preserve concurrent local changes.
    • Made preference updates apply more promptly by syncing updates immediately.

Walkthrough

The preference composable changes the default page size, synchronizes watcher updates, reconciles server snapshots with concurrent local edits, and coordinates retries when saving settings fails.

Changes

Preference synchronization

Layer / File(s) Summary
Preference defaults and propagation
frontend/composables/use-preferences.ts
The default table page size changes from 10 to 12, and the deep preference watcher now flushes synchronously.
Server snapshot reconciliation
frontend/composables/use-preferences.ts
New cloning, diffing, recursive merge, and server-fetch helpers preserve local edits while applying refreshed server settings.
Server save retry coordination
frontend/composables/use-preferences.ts
Pending retry timers block additional saves, thrown settings API errors schedule retries, and follow-up saves are skipped while a retry remains scheduled.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PreferenceWatcher
  participant PreferenceSync
  participant SettingsAPI
  PreferenceWatcher->>PreferenceSync: detect synchronous preference change
  PreferenceSync->>SettingsAPI: save settings
  SettingsAPI-->>PreferenceSync: return settings or throw error
  PreferenceSync->>PreferenceSync: schedule retry or reconcile pending changes
Loading

Possibly related PRs

Suggested reviewers: tankerkiller125

Poem

Twelve rows gather in the view,
Local changes join the queue.
Server snapshots merge with care,
Failed saves retry through the air.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main changes: the sync loop fix and default page size update.
Description check ✅ Passed The description covers type, purpose, issue reference, and reviewer notes; testing is optional here.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code

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.

@coderabbitai
coderabbitai Bot requested a review from tankerkiller125 July 24, 2026 09:37

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/composables/use-preferences.ts`:
- Line 234: In the server-refresh flow around refreshViewPreferencesFromServer,
limit applyingServerSnapshot to only the synchronous preferences.value =
mergeSyncedSettings(...) assignment, clearing it before the awaited refresh
completes so edits during the pending request invoke markDirty() and increment
localRevision. Add a regression test covering a preference edit made before the
refresh resolves and verify the edit is not overwritten.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ac85487e-3126-4724-9f77-33d95681cddb

📥 Commits

Reviewing files that changed from the base of the PR and between 52eb6f3 and 3e36a51.

📒 Files selected for processing (1)
  • frontend/composables/use-preferences.ts

markDirty();
},
{ deep: true }
{ deep: true, flush: "sync" }

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant composables file without running repository code.
if git ls-files | grep -Fx 'frontend/composables/use-preferences.ts' > /dev/null; then
  echo "== file exists =="
  wc -l frontend/composables/use-preferences.ts
  echo
  echo "== outline =="
  ast-grep outline frontend/composables/use-preferences.ts --view compact || true
  echo
  echo "== relevant sections =="
  sed -n '160,270p' frontend/composables/use-preferences.ts
else
  echo "frontend/composables/use-preferences.ts not found"
  echo "matching files:"
  git ls-files | grep -i 'use-preferences' || true
fi

echo
echo "== watcher configuration usage =="
rg -n 'watch\s*\(|flush:\s*"sync"|applyingServerSnapshot|localRevision|refreshViewPreferencesFromServer' frontend/composables/use-preferences.ts

Repository: sysadminsmedia/homebox

Length of output: 3448


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== import / helpers / preference mutations =="
sed -n '1,175p' frontend/composables/use-preferences.ts

echo
echo "== Vue package/version references for local environment constraints =="
git ls-files | grep -E '(^|/)(package.json|pnpm-lock.yaml|yarn.lock|package-lock.json)$' | while read -r f; do
  echo "--- $f"
  rg -n '"vue"|github:vuejs/vue|vuejs/vue|flush' "$f" | head -50 || true
done

Repository: sysadminsmedia/homebox

Length of output: 4908


🌐 Web query:

Vue 3 watch flush sync synchronous before DOM updates unbatched documentation

💡 Result:

In Vue 3, the flush: 'sync' option for watchers (watch and watchEffect) forces the watcher callback to execute synchronously as soon as a reactive dependency is mutated [1][2]. Unlike the default flush: 'pre' or the flush: 'post' options, which queue effects in a scheduler to batch updates, flush: 'sync' bypasses this batching entirely [1][3]. Key characteristics of flush: 'sync' include: - Immediate Execution: The callback is invoked immediately upon the change of a reactive dependency, before any Vue-managed component updates or subsequent state mutations are processed [1][2]. - No Batching: Because it is unbatched, if multiple reactive dependencies are mutated in a single synchronous block, the watcher will trigger repeatedly for each individual mutation [1]. - Use with Caution: Vue documentation strongly advises caution when using flush: 'sync'. It can lead to performance issues and data consistency problems—such as observing partial state updates—especially if watched sources are mutated frequently (e.g., arrays or complex objects) [1][2]. For most use cases, the default flush: 'pre' (before DOM updates) or flush: 'post' (after DOM updates) is preferred [1]. watchSyncEffect() is available as a convenient alias for watchEffect(() => {}, { flush: 'sync' }) [1][2].

Citations:


Scope server-snapshot suppression to snapshot application only.

applyingServerSnapshot stays true during the full await of refreshViewPreferencesFromServer(preferences). An edit made while that refresh is pending will invoke the synchronous watcher after assignment, and markDirty() will not increment localRevision, so the later snapshot can overwrite the user input unless the server snapshot assigns exactly the current local value. Keep the suppression limited to preferences.value = mergeSyncedSettings(...) and add a regression test for editing preferences before refresh completes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/composables/use-preferences.ts` at line 234, In the server-refresh
flow around refreshViewPreferencesFromServer, limit applyingServerSnapshot to
only the synchronous preferences.value = mergeSyncedSettings(...) assignment,
clearing it before the awaited refresh completes so edits during the pending
request invoke markDirty() and increment localRevision. Add a regression test
covering a preference edit made before the refresh resolves and verify the edit
is not overwritten.

Source: MCP tools

@IceeAn
IceeAn force-pushed the fix/preference-fixes branch from 3e36a51 to cb90a49 Compare July 24, 2026 10:05
@IceeAn

IceeAn commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Preference merging now preserves local edits made while a server refresh is in progress.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/composables/use-preferences.ts`:
- Around line 89-118: Guard both merge/diff loops against the keys "__proto__",
"constructor", and "prototype" before reading or assigning them. Update
mergeSyncedValue and the analogous getPreferenceChange builder to skip these
keys, preserving normal handling for all other preference fields and preventing
prototype mutation on the result objects.
- Around line 246-253: Update the setSettings handling in the preferences sync
flow to remove the error variable and the subsequent error check, since the
Result response only exposes item. Retain the existing catch block and
scheduleRetry behavior for rejected requests.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 73c6d814-d4ad-445a-b503-0639c63dc797

📥 Commits

Reviewing files that changed from the base of the PR and between 3e36a51 and cb90a49.

📒 Files selected for processing (1)
  • frontend/composables/use-preferences.ts

Comment on lines +89 to +118
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function mergeSyncedValue(serverValue: unknown, localValue: unknown, localChange?: PreferenceChange): unknown {
if (localChange === undefined) {
return serverValue;
}

if (localChange === true || !isPlainObject(serverValue) || !isPlainObject(localValue)) {
return localValue;
}

const mergedValue: Record<string, unknown> = { ...serverValue };
const keys = new Set([...Object.keys(serverValue), ...Object.keys(localValue)]);

for (const key of keys) {
const nestedChange = localChange[key];
if (nestedChange !== undefined) {
mergedValue[key] = mergeSyncedValue(serverValue[key], localValue[key], nestedChange);
continue;
}

if (!(key in mergedValue)) {
mergedValue[key] = localValue[key];
}
}

return mergedValue;
}

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Guard against __proto__/constructor/prototype keys in the merge/diff loops.

mergeSyncedValue iterates Object.keys(serverValue) ∪ Object.keys(localValue) and writes via mergedValue[key] = ...; the analogous diff builder (getPreferenceChange) does the same into a fresh changedFields = {}. Since serverValue originates from JSON.parse'd server data, a payload containing a literal "__proto__" key is a normal own property there, but once it's re-assigned via computed bracket notation onto an object that doesn't already own that key, the assignment invokes the real __proto__ setter and mutates that object's prototype link instead of a plain data property. Skip these dangerous keys defensively.

🛡️ Proposed fix
+const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
+
 function mergeSyncedValue(serverValue: unknown, localValue: unknown, localChange?: PreferenceChange): unknown {
   ...
   for (const key of keys) {
+    if (UNSAFE_KEYS.has(key)) {
+      continue;
+    }
+
     const nestedChange = localChange[key];
     for (const key of keys) {
+      if (UNSAFE_KEYS.has(key)) {
+        continue;
+      }
+
       const nestedChange = getPreferenceChange(previousValue[key], nextValue[key]);

Also applies to: 140-178

🧰 Tools
🪛 ast-grep (0.44.1)

[error] 104-114: Recursive/iterative merge copies attacker-controllable keys from a source object into a target via a computed property assignment without rejecting dangerous keys, allowing prototype pollution. Skip or block "proto", "constructor", and "prototype" keys (e.g. if (key === "__proto__" || key === "constructor" || key === "prototype") continue;), use a null-prototype object (Object.create(null)), or use a safe merge utility instead.
Context: for (const key of keys) {
const nestedChange = localChange[key];
if (nestedChange !== undefined) {
mergedValue[key] = mergeSyncedValue(serverValue[key], localValue[key], nestedChange);
continue;
}

if (!(key in mergedValue)) {
  mergedValue[key] = localValue[key];
}

}
Note: [CWE-1321] Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution').

(prototype-pollution-recursive-merge-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/composables/use-preferences.ts` around lines 89 - 118, Guard both
merge/diff loops against the keys "__proto__", "constructor", and "prototype"
before reading or assigning them. Update mergeSyncedValue and the analogous
getPreferenceChange builder to skip these keys, preserving normal handling for
all other preference fields and preventing prototype mutation on the result
objects.

Source: Linters/SAST tools

Comment on lines +246 to +253
let error = false;
try {
({ error } = await api.user.setSettings(buildSyncedSettings(preferences.value)));
} catch {
scheduleRetry();
return;
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n "type Result" frontend/lib -A 10
rg -n "public setSettings" -A 6 frontend/lib/api/classes/users.ts
ast-grep run --pattern 'interface Result<$T> { $$$ }' --lang typescript frontend/lib

Repository: sysadminsmedia/homebox

Length of output: 979


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== non-generated API result type =="
cat -n frontend/lib/api/types/non-generated.ts

echo
echo "== users.ts outline and relevant methods =="
wc -l frontend/lib/api/classes/users.ts
ast-grep outline frontend/lib/api/classes/users.ts --view expanded || true
sed -n '1,80p' frontend/lib/api/classes/users.ts

echo
echo "== use-preferences around setSettings usage =="
wc -l frontend/composables/use-preferences.ts
sed -n '220,270p' frontend/composables/use-preferences.ts

echo
echo "== search Result definitions/usages =="
rg -n "export type Result|interface Result|class Result|type Result" frontend/lib frontend/composables -A 8 -B 2

echo
echo "== http implementation type helpers if present =="
rg -n "class Api|interface Http|put<|\.put<" frontend/lib/api -A 8 -B 2

Repository: sysadminsmedia/homebox

Length of output: 12723


Drop the error check; Result<T> has no error field.

UserApi.setSettings(...) returns Result<Record<string, unknown>>, and frontend/lib/api/types/non-generated.ts defines Result<T> as { item: T }, so ({ error } = await ...) and the subsequent if (error) have no matching response shape.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/composables/use-preferences.ts` around lines 246 - 253, Update the
setSettings handling in the preferences sync flow to remove the error variable
and the subsequent error check, since the Result response only exposes item.
Retain the existing catch block and scheduleRetry behavior for rejected
requests.

@IceeAn
IceeAn marked this pull request as draft July 24, 2026 10:16
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.

1 participant