Account settings can stop being written to Stalwart after a language-catalog update remounts AuthedApp during initial settings loading. Reading settings.json succeeds, but subsequent changes are lost after reload or sign-out/sign-in.
Reproduction
Use an account whose saved settings select a non-English language.
Sign in without a cached language catalog/settings, so remote settings hydration triggers language loading.
After the interface loads, change a synced setting, such as the theme.
Wait beyond the settings-write debounce, then hard-refresh or sign out and back in.
Actual: the change can revert because settings writes were never armed.
Expected: changes are written to the account's settings file and survive reload and sign-in.
The trigger depends on the language-loading lifecycle; the component regression test deterministically covers the already-loaded remount path.
Environment and prior validation
Originally reproduced on ihasmail 2026.9.18+gc118184.
The patched deployment preserved language, theme and other settings after hard refresh and sign-out/sign-in. Its Docker build succeeded.
Root cause
settingsAlreadyLoadedFor(accountId) claims the account on the first mount. Remote settings hydration can load a language catalog, changing the key of the subtree containing AuthedApp. The first effect is canceled before reaching armSettingsSync(). The replacement mount takes the already-loaded early-return, which sets readiness but does not arm writes.
This early-return is still present on official upstream main at 42e1f4ca60 (checked 2026-10-03).
Proposed fix
Call armSettingsSync() immediately after setReady(true) in the settingsAlreadyLoadedFor(accountId) early-return in web/src/App.tsx. Keep the existing guard against reloading stale remote settings.
Add a component regression test asserting that this path arms writes without calling loadRemoteSettings() again; export AuthedApp to enable the test.
On previously tested upstream revision 23557a72a2 plus this change, the regression test passed. Removing only the added call made it fail with zero calls to armSettingsSync(); restoring the call made it pass again. Tests have not yet been rerun on the latest upstream revision.
## Summary
Account settings can stop being written to Stalwart after a language-catalog update remounts AuthedApp during initial settings loading. Reading settings.json succeeds, but subsequent changes are lost after reload or sign-out/sign-in.
## Reproduction
1. Use an account whose saved settings select a non-English language.
2. Sign in without a cached language catalog/settings, so remote settings hydration triggers language loading.
3. After the interface loads, change a synced setting, such as the theme.
4. Wait beyond the settings-write debounce, then hard-refresh or sign out and back in.
Actual: the change can revert because settings writes were never armed.
Expected: changes are written to the account's settings file and survive reload and sign-in.
The trigger depends on the language-loading lifecycle; the component regression test deterministically covers the already-loaded remount path.
## Environment and prior validation
- Originally reproduced on ihasmail 2026.9.18+gc118184.
- Self-hosted Stalwart 0.16.22; Docker deployment; Firefox.
- Fresh Stalwart instance: not separately tested.
- The patched deployment preserved language, theme and other settings after hard refresh and sign-out/sign-in. Its Docker build succeeded.
## Root cause
settingsAlreadyLoadedFor(accountId) claims the account on the first mount. Remote settings hydration can load a language catalog, changing the key of the subtree containing AuthedApp. The first effect is canceled before reaching armSettingsSync(). The replacement mount takes the already-loaded early-return, which sets readiness but does not arm writes.
This early-return is still present on official upstream main at 42e1f4ca60e1cdfda28206c4a68e7d5a355c0d4c (checked 2026-10-03).
## Proposed fix
Call armSettingsSync() immediately after setReady(true) in the settingsAlreadyLoadedFor(accountId) early-return in web/src/App.tsx. Keep the existing guard against reloading stale remote settings.
Add a component regression test asserting that this path arms writes without calling loadRemoteSettings() again; export AuthedApp to enable the test.
On previously tested upstream revision 23557a72a2a72088081792f8ea0cabedea4e7bcb plus this change, the regression test passed. Removing only the added call made it fail with zero calls to armSettingsSync(); restoring the call made it pass again. Tests have not yet been rerun on the latest upstream revision.
jcoffey-dev
self-assigned this 2026-10-03 22:18:46 +00:00
Thank you for the detailed report, @githubrepo. The root-cause analysis and the regression test made this one quick to track down.
Fixed in #29. I went one step further than the proposed one-liner: arming writes in the early-return could push the browser's settings over the account's file when the remount comes before the file has been read. So the settings load now belongs to the account rather than the mount. It runs once, every mount waits on it, and it arms writes when it finishes. In dev, React's StrictMode remount was hitting the same path, so there the file was never applied at all.
It will be in the next release. Thanks again!
Thank you for the detailed report, @githubrepo. The root-cause analysis and the regression test made this one quick to track down.
Fixed in #29. I went one step further than the proposed one-liner: arming writes in the early-return could push the browser's settings over the account's file when the remount comes before the file has been read. So the settings load now belongs to the account rather than the mount. It runs once, every mount waits on it, and it arms writes when it finishes. In dev, React's StrictMode remount was hitting the same path, so there the file was never applied at all.
It will be in the next release. Thanks again!
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Account settings can stop being written to Stalwart after a language-catalog update remounts AuthedApp during initial settings loading. Reading settings.json succeeds, but subsequent changes are lost after reload or sign-out/sign-in.
Reproduction
Actual: the change can revert because settings writes were never armed.
Expected: changes are written to the account's settings file and survive reload and sign-in.
The trigger depends on the language-loading lifecycle; the component regression test deterministically covers the already-loaded remount path.
Environment and prior validation
Root cause
settingsAlreadyLoadedFor(accountId) claims the account on the first mount. Remote settings hydration can load a language catalog, changing the key of the subtree containing AuthedApp. The first effect is canceled before reaching armSettingsSync(). The replacement mount takes the already-loaded early-return, which sets readiness but does not arm writes.
This early-return is still present on official upstream main at
42e1f4ca60(checked 2026-10-03).Proposed fix
Call armSettingsSync() immediately after setReady(true) in the settingsAlreadyLoadedFor(accountId) early-return in web/src/App.tsx. Keep the existing guard against reloading stale remote settings.
Add a component regression test asserting that this path arms writes without calling loadRemoteSettings() again; export AuthedApp to enable the test.
On previously tested upstream revision
23557a72a2plus this change, the regression test passed. Removing only the added call made it fail with zero calls to armSettingsSync(); restoring the call made it pass again. Tests have not yet been rerun on the latest upstream revision.Thank you for the detailed report, @githubrepo. The root-cause analysis and the regression test made this one quick to track down.
Fixed in #29. I went one step further than the proposed one-liner: arming writes in the early-return could push the browser's settings over the account's file when the remount comes before the file has been read. So the settings load now belongs to the account rather than the mount. It runs once, every mount waits on it, and it arms writes when it finishes. In dev, React's StrictMode remount was hitting the same path, so there the file was never applied at all.
It will be in the next release. Thanks again!