You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix: stop Canvas Navigation mode resetting to Custom on reload (#14716)
*PR Created by the Glary-Bot Agent*
---
Fixes
[FE-1503](https://linear.app/comfyorg/issue/FE-1503/bug-canvas-navigation-setting-resets-to-custom-after-page-refresh).
> Stacked on #14714, which repairs `typecheck:browser` on `main`. Review
that first; this PR targets its branch so the diff stays clean.
## Problem
Pick a Navigation Mode, refresh, and the dropdown reads **Custom** —
while the Left Mouse Click Behavior / Mouse Wheel Scroll radios still
show the preset you chose.
`Comfy.Canvas.NavigationMode` is stored independently of the two
overrides it implies, and the three were kept in sync by cross-writes in
`onChange`. Two separate paths destroyed the stored mode.
**1. The preset cascade read a stale mode.** `applySettingLocally` fired
`onChange` *before* committing the new value:
```ts
const oldValue = get(key)
if (newValue === oldValue) return undefined
onChange(settingsById.value[key], newValue, oldValue) // fired first
settingValues.value[key] = typedNewValue // committed second
```
So selecting `standard` cascaded into `setMany`, and
`LeftMouseClickBehavior.onChange` read `NavigationMode` as the value it
was replacing (`legacy`), concluded `select` no longer matched it, and
wrote `NavigationMode = 'custom'`. Captured from one dropdown click:
```
POST /settings/Comfy.Canvas.NavigationMode "custom" ← spurious
POST /settings {LeftMouseClickBehavior:"select", MouseWheelScroll:"panning"}
POST /settings/Comfy.Canvas.NavigationMode "standard"
```
Three concurrent writes, two to the same key. ComfyUI's
`app_settings.py` has an `await request.json()` between its file read
and write, so these whole-file read-modify-writes are not atomic and the
last one to land wins.
**2. A mode stored before 1.27.4 was overruled by the override
defaults.** `addSetting` replays `onChange` for every setting at
registration. The overrides shipped in 1.27.4; `NavigationMode` shipped
in 1.25.0. Anyone who chose a mode in between has only the mode on
record, so the overrides load as their defaults — `panning`/`zoom`,
which describe `legacy`. The override handlers saw the mismatch and
rewrote the mode to `custom` on the first load after upgrading, with no
user interaction. Deterministic, and it explains "has been there for
quite some time" on both Windows and Mac.
Once `custom` is stored the handlers no-op, so it never recovers. This
also isn't purely cosmetic: `useCanvasInteractions` gates on
`NavigationMode === 'standard'` exactly, so the standard-mode wheel path
silently turns off.
## Fix
- **Commit the value before firing `onChange`** so a cascade observes
the mode it is applying. This removes the spurious write entirely,
leaving one write per key.
- **Treat a stored preset as authoritative.** On the registration
replay, a stored preset now supplies the overrides that were never
stored, instead of being overruled by their defaults. The preset pairs
move into one `CANVAS_NAVIGATION_PRESETS` map rather than being restated
per branch.
Muting the override handlers during registration was the smaller change
and I started there, but review caught that it only fixes the label: the
dropdown would read Standard while the canvas still panned and zoomed
like Legacy. Making the preset supply its missing overrides fixes both,
and affected profiles self-heal on next load.
## Tests
Written first, and each fails on `main`:
| Test | On `main` |
|---|---|
| picking a preset never persists custom | `Received array: ["custom",
"standard"]` |
| keeps the stored preset through load | `Expected "standard"`,
`Received "custom"` |
| applies the stored preset to the overrides | `Expected "select"`,
`Received "panning"` |
The third is the one that would have caught the label-only fix. The unit
test in `settingStore.test.ts` pins the ordering contract directly
(`['default','default']` vs `['default','newvalue']` without the fix),
since it governs every setting, not just this cluster.
## Verification
Reproduced and confirmed end to end against a local ComfyUI backend.
Seeded a pre-1.27.4 profile (`NavigationMode: 'standard'`, overrides
absent) and loaded the page with no user interaction:
**Before** — `comfy.settings.json` silently rewritten to `custom`;
dropdown reads Custom while the radios still show the Standard pair:

**After** — mode holds, and the overrides materialise to
`select`/`panning` so label and behaviour agree:

- 3 Playwright tests + `settingStore` unit test: fail on `main`, pass
here
- `canvasSettings.spec.ts`: 5 pre-existing failures in this sandbox
(headless canvas drag + screenshot baselines) — identical set on clean
`main`; baseline 8 passed → 10 passed here
- `pnpm test:unit`: 3 pre-existing failing files (`previewAny`,
`onboardingCloudRoutes`, `GraphView`), confirmed failing on clean
`main`, none in the settings domain
- `pnpm typecheck`, `typecheck:browser`, `lint`, `format:check`, `knip`
— all clean
## Follow-ups (not in scope here)
- `Comfy.Canvas.NavigationMode` would be better derived from the two
overrides than stored as a third key — one source of truth, no
cross-setting cascade. That removes this bug class rather than this
instance.
- `POST /settings` and `POST /settings/{id}` in ComfyUI's
`app/app_settings.py` do non-atomic read-modify-write of one JSON file
with an `await` between read and write, so any concurrent setting writes
can lose updates.
-
[FE-1507](https://linear.app/comfyorg/issue/FE-1507/ops-run-typecheckbrowser-unconditionally-so-browser-tests-type-errors)
— make `typecheck:browser` unconditional in CI.
## Screenshots


---------
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
0 commit comments