Skip to content

Commit 718db8b

Browse files
DrJKLGlary-Bot
andauthored
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: ![before](https://placeholder-before) **After** — mode holds, and the overrides materialise to `select`/`panning` so label and behaviour agree: ![after](https://placeholder-after) - 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 ![Before: after a reload Navigation Mode reads Custom while Left Mouse Click Behavior is Select and Mouse Wheel Scroll is Panning - the Standard preset](https://pub-0c501c1cf38f477fa8efc67068523b33.r2.dev/sessions/b4bf4aad7065f79965deac7b000dd8c53f46c9c4ed7f6af6c7e44d714af85512/pr-images/1785882191788-d210acf4-ad52-4822-8a25-a4fe69526fc1.png) ![After: Navigation Mode retains Standard (New) across a reload, with Select and Panning consistent with it](https://pub-0c501c1cf38f477fa8efc67068523b33.r2.dev/sessions/b4bf4aad7065f79965deac7b000dd8c53f46c9c4ed7f6af6c7e44d714af85512/pr-images/1785882192021-8b769799-9112-48bb-a216-51e6d923faaf.png) --------- Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
1 parent fb24726 commit 718db8b

8 files changed

Lines changed: 512 additions & 30 deletions

File tree

browser_tests/fixtures/helpers/SettingsHelper.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,18 @@ export class SettingsHelper {
2020
return await window.app!.extensionManager.setting.get(id)
2121
}, settingId)) as T
2222
}
23+
24+
/**
25+
* Reads the value the server holds, which {@link getSetting} cannot: that
26+
* reports the in-memory store, where a value may have been derived at load
27+
* rather than persisted. Returns `undefined` for a setting never written.
28+
*/
29+
async getPersistedSetting<T = unknown>(
30+
settingId: string
31+
): Promise<T | undefined> {
32+
return (await this.page.evaluate(async (id) => {
33+
const persisted = await window.app!.api.getSettings()
34+
return persisted[id as keyof typeof persisted]
35+
}, settingId)) as T | undefined
36+
}
2337
}

browser_tests/tests/canvasSettings.spec.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,138 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
188188
})
189189
})
190190

191+
test.describe('Comfy.Canvas.NavigationMode', () => {
192+
test('picking a preset never persists custom', async ({ comfyPage }) => {
193+
const modeWrites: unknown[] = []
194+
comfyPage.page.on('request', (request) => {
195+
if (
196+
request.method() === 'POST' &&
197+
request.url().endsWith('/api/settings/Comfy.Canvas.NavigationMode')
198+
) {
199+
modeWrites.push(request.postDataJSON())
200+
}
201+
})
202+
203+
await comfyPage.settings.setSetting(
204+
'Comfy.Canvas.NavigationMode',
205+
'standard'
206+
)
207+
await comfyPage.workflow.reloadAndWaitForApp()
208+
209+
// A preset also writes the two overrides it implies; those writes must
210+
// not read back as the user hand-picking an override.
211+
expect(modeWrites).toContain('standard')
212+
expect(modeWrites).not.toContain('custom')
213+
expect(
214+
await comfyPage.settings.getSetting('Comfy.Canvas.NavigationMode')
215+
).toBe('standard')
216+
})
217+
218+
test('picking custom leaves the overrides untouched', async ({
219+
comfyPage
220+
}) => {
221+
// Arrive on the standard pair first, so both overrides differ from their
222+
// defaults and a handler that rewrote them would be caught.
223+
await comfyPage.settings.setSetting(
224+
'Comfy.Canvas.NavigationMode',
225+
'standard'
226+
)
227+
228+
await comfyPage.settings.setSetting(
229+
'Comfy.Canvas.NavigationMode',
230+
'custom'
231+
)
232+
await comfyPage.workflow.reloadAndWaitForApp()
233+
234+
expect(
235+
await comfyPage.settings.getSetting('Comfy.Canvas.NavigationMode')
236+
).toBe('custom')
237+
expect(
238+
await comfyPage.settings.getSetting(
239+
'Comfy.Canvas.LeftMouseClickBehavior'
240+
)
241+
).toBe('select')
242+
expect(
243+
await comfyPage.settings.getSetting('Comfy.Canvas.MouseWheelScroll')
244+
).toBe('panning')
245+
})
246+
247+
// A mode stored before the overrides shipped in 1.27.4 is the only value on
248+
// record, so they load as their defaults — which describe a different mode.
249+
test.describe('stored without the override settings', () => {
250+
test.use({
251+
initialSettings: { 'Comfy.Canvas.NavigationMode': 'standard' }
252+
})
253+
254+
test('keeps the stored preset through load', async ({ comfyPage }) => {
255+
expect(
256+
await comfyPage.settings.getSetting('Comfy.Canvas.NavigationMode')
257+
).toBe('standard')
258+
})
259+
260+
// Reads the server, not the store: the migration is idempotent, so an
261+
// in-memory read passes whether or not the write ever landed.
262+
test('persists the stored preset to the overrides', async ({
263+
comfyPage
264+
}) => {
265+
expect(
266+
await comfyPage.settings.getPersistedSetting(
267+
'Comfy.Canvas.LeftMouseClickBehavior'
268+
)
269+
).toBe('select')
270+
expect(
271+
await comfyPage.settings.getPersistedSetting(
272+
'Comfy.Canvas.MouseWheelScroll'
273+
)
274+
).toBe('panning')
275+
})
276+
})
277+
278+
// Every profile that already loaded a 1.27.4+ build has the mode demoted to
279+
// 'custom' with the overrides never written. The original choice is
280+
// unrecoverable, so this pins the no-op as deliberate.
281+
test.describe('already demoted to custom', () => {
282+
test.use({
283+
initialSettings: { 'Comfy.Canvas.NavigationMode': 'custom' }
284+
})
285+
286+
test('is left as custom', async ({ comfyPage }) => {
287+
expect(
288+
await comfyPage.settings.getSetting('Comfy.Canvas.NavigationMode')
289+
).toBe('custom')
290+
})
291+
})
292+
293+
test.describe('stored with only one override', () => {
294+
test.use({
295+
initialSettings: {
296+
'Comfy.Canvas.NavigationMode': 'standard',
297+
'Comfy.Canvas.MouseWheelScroll': 'zoom'
298+
}
299+
})
300+
301+
test('fills the gap without overwriting the stored override', async ({
302+
comfyPage
303+
}) => {
304+
expect(
305+
await comfyPage.settings.getSetting(
306+
'Comfy.Canvas.LeftMouseClickBehavior'
307+
)
308+
).toBe('select')
309+
expect(
310+
await comfyPage.settings.getSetting('Comfy.Canvas.MouseWheelScroll')
311+
).toBe('zoom')
312+
313+
// select + zoom is no preset, so demoting the mode is correct here.
314+
// Overwriting the stored 'zoom' to match the mode instead would
315+
// discard a real preference, which is the bug this all started as.
316+
expect(
317+
await comfyPage.settings.getSetting('Comfy.Canvas.NavigationMode')
318+
).toBe('custom')
319+
})
320+
})
321+
})
322+
191323
test.describe('Comfy.Canvas.LeftMouseClickBehavior', () => {
192324
test('override to panning makes empty left-drag pan the canvas', async ({
193325
comfyPage
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { CANVAS_NAVIGATION_PRESETS } from '@/platform/settings/constants/canvasNavigation'
4+
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
5+
import type { SettingParams } from '@/platform/settings/types'
6+
7+
const NAV = 'Comfy.Canvas.NavigationMode'
8+
const LEFT = 'Comfy.Canvas.LeftMouseClickBehavior'
9+
const WHEEL = 'Comfy.Canvas.MouseWheelScroll'
10+
11+
const settingById = (id: string) => CORE_SETTINGS.find((s) => s.id === id)
12+
13+
const resolveDefaultValue = (setting: SettingParams | undefined): unknown => {
14+
const { defaultValue } = setting ?? {}
15+
return typeof defaultValue === 'function'
16+
? (defaultValue as () => unknown)()
17+
: defaultValue
18+
}
19+
20+
const presetForMode = (mode: unknown) =>
21+
typeof mode === 'string' ? CANVAS_NAVIGATION_PRESETS[mode] : undefined
22+
23+
const overrideDefaults = () => ({
24+
[LEFT]: resolveDefaultValue(settingById(LEFT)),
25+
[WHEEL]: resolveDefaultValue(settingById(WHEEL))
26+
})
27+
28+
describe('CANVAS_NAVIGATION_PRESETS', () => {
29+
/**
30+
* The override defaults have to describe whichever Navigation Mode a fresh
31+
* profile resolves to. If they disagree, that profile loads with a mode no
32+
* preset matches and the override handlers demote it to 'custom' on first
33+
* load — the bug this pairing exists to prevent. Asserted as a relationship
34+
* rather than against fixed values so changing a default is what trips it.
35+
*/
36+
it('agrees with the default Navigation Mode', () => {
37+
const defaultMode = resolveDefaultValue(settingById(NAV))
38+
39+
expect(presetForMode(defaultMode)).toEqual(overrideDefaults())
40+
})
41+
42+
it('agrees with every install-versioned Navigation Mode default', () => {
43+
const versioned = settingById(NAV)?.defaultsByInstallVersion ?? {}
44+
45+
for (const mode of Object.values(versioned)) {
46+
expect(presetForMode(mode)).toEqual(overrideDefaults())
47+
}
48+
})
49+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { Settings } from '@/schemas/apiSchema'
2+
3+
/**
4+
* The Left Mouse Click Behavior and Mouse Wheel Scroll values each Navigation
5+
* Mode preset stands for.
6+
*
7+
* `custom` is deliberately absent: it means "the overrides are whatever the
8+
* user set", so there is no pair to apply or to restore from it.
9+
*/
10+
export const CANVAS_NAVIGATION_PRESETS: Record<
11+
string,
12+
Partial<Settings> | undefined
13+
> = {
14+
standard: {
15+
'Comfy.Canvas.LeftMouseClickBehavior': 'select',
16+
'Comfy.Canvas.MouseWheelScroll': 'panning'
17+
},
18+
legacy: {
19+
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
20+
'Comfy.Canvas.MouseWheelScroll': 'zoom'
21+
}
22+
}

src/platform/settings/constants/coreSettings.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from '@/locales/localeConfig'
66
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
77
import { TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
8+
import { CANVAS_NAVIGATION_PRESETS } from '@/platform/settings/constants/canvasNavigation'
89
import { useSettingStore } from '@/platform/settings/settingStore'
910
import type { SettingParams } from '@/platform/settings/types'
1011
import type { ColorPalettes } from '@/schemas/colorPaletteSchema'
@@ -188,22 +189,11 @@ export const CORE_SETTINGS: SettingParams[] = [
188189
'1.25.0': 'legacy'
189190
},
190191
onChange: async (val: unknown, old?: unknown) => {
191-
const newValue = val as string
192-
const oldValue = old as string | undefined
193-
if (!oldValue) return
194-
const settingStore = useSettingStore()
192+
if (!old || typeof val !== 'string') return
193+
const preset = CANVAS_NAVIGATION_PRESETS[val]
194+
if (!preset) return
195195

196-
if (newValue === 'standard') {
197-
await settingStore.setMany({
198-
'Comfy.Canvas.LeftMouseClickBehavior': 'select',
199-
'Comfy.Canvas.MouseWheelScroll': 'panning'
200-
})
201-
} else if (newValue === 'legacy') {
202-
await settingStore.setMany({
203-
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
204-
'Comfy.Canvas.MouseWheelScroll': 'zoom'
205-
})
206-
}
196+
await useSettingStore().setMany(preset)
207197
}
208198
},
209199
{

0 commit comments

Comments
 (0)