Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions browser_tests/fixtures/helpers/SettingsHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,18 @@ export class SettingsHelper {
return await window.app!.extensionManager.setting.get(id)
}, settingId)) as T
}

/**
* Reads the value the server holds, which {@link getSetting} cannot: that
* reports the in-memory store, where a value may have been derived at load
* rather than persisted. Returns `undefined` for a setting never written.
*/
async getPersistedSetting<T = unknown>(
settingId: string
): Promise<T | undefined> {
return (await this.page.evaluate(async (id) => {
const persisted = await window.app!.api.getSettings()
return persisted[id as keyof typeof persisted]
}, settingId)) as T | undefined
}
}
132 changes: 132 additions & 0 deletions browser_tests/tests/canvasSettings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,138 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
})
})

test.describe('Comfy.Canvas.NavigationMode', () => {
test('picking a preset never persists custom', async ({ comfyPage }) => {
const modeWrites: unknown[] = []
comfyPage.page.on('request', (request) => {
if (
request.method() === 'POST' &&
request.url().endsWith('/api/settings/Comfy.Canvas.NavigationMode')
) {
modeWrites.push(request.postDataJSON())
}
})

await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'standard'
)
await comfyPage.workflow.reloadAndWaitForApp()

// A preset also writes the two overrides it implies; those writes must
// not read back as the user hand-picking an override.
expect(modeWrites).toContain('standard')
expect(modeWrites).not.toContain('custom')
expect(
await comfyPage.settings.getSetting('Comfy.Canvas.NavigationMode')
).toBe('standard')
})

test('picking custom leaves the overrides untouched', async ({
comfyPage
}) => {
// Arrive on the standard pair first, so both overrides differ from their
// defaults and a handler that rewrote them would be caught.
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'standard'
)

await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'custom'
)
await comfyPage.workflow.reloadAndWaitForApp()

expect(
await comfyPage.settings.getSetting('Comfy.Canvas.NavigationMode')
).toBe('custom')
expect(
await comfyPage.settings.getSetting(
'Comfy.Canvas.LeftMouseClickBehavior'
)
).toBe('select')
expect(
await comfyPage.settings.getSetting('Comfy.Canvas.MouseWheelScroll')
).toBe('panning')
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// A mode stored before the overrides shipped in 1.27.4 is the only value on
// record, so they load as their defaults — which describe a different mode.
test.describe('stored without the override settings', () => {

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.

The three tests are well chosen and I verified the fixture actually supports them — comfyPageFixture's default setupSettings block seeds neither Comfy.Canvas.LeftMouseClickBehavior nor Comfy.Canvas.MouseWheelScroll, so test.use({ initialSettings: { 'Comfy.Canvas.NavigationMode': 'standard' } }) really does reproduce a pre-1.27.4 profile rather than quietly inheriting the overrides. Good.

Gaps I would like closed before this merges:

  • The already-demoted profile is untested. { 'Comfy.Canvas.NavigationMode': 'custom' } with the overrides absent is the state every user who has already loaded 1.27.4+ is in, and this PR deliberately no-ops on it (see my comment on line 204). Right now that is indistinguishable from an oversight.
  • The partial case is untested. The .filter(([id]) => !exists(id)) only ever runs with both overrides missing. A profile with NavigationMode: 'standard' and, say, only MouseWheelScroll stored exercises the filter for real — and it is the case where "supply only what is missing" could plausibly do the wrong thing.
  • custom short-circuit is untested. Nothing pins that picking Custom does not cascade.
  • "applies the stored preset to the overrides" only proves the in-memory store. comfyPage.settings.getSetting resolves to extensionManager.setting.get, which reads settingValues. Since the healing setMany is fired from an un-awaited handler, the assertion passes even if the POST /settings that persists the healed overrides never lands. A reloadAndWaitForApp() before the assertions would turn this into a persistence test — which is what the fix actually claims.

Minor: in "picking a preset never persists custom", request.url().endsWith('/api/settings/Comfy.Canvas.NavigationMode') will silently match nothing if a query string is ever appended, and the test would then pass vacuously. .includes() on the path, or asserting modeWrites.length > 0 alongside the not.toContain, makes the test fail loudly instead of quietly.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All four gaps closed, and the partial case earned its keep immediately — it failed, and the bug was in my expectation, not the code.

I wrote it asserting the mode stays standard, and got:

Expected: "standard"
Received: "custom"

Correctly so. With {nav: standard, wheel: zoom} the migration fills only left: 'select', and select + zoom is no preset, so the replay demotes to custom — the honest label for a genuinely mixed pair. The alternative would be overwriting the stored zoom to match the mode, which discards an explicit preference and is the exact failure mode this PR exists to fix. So the test now pins gap-filling-without-overwriting, with a comment explaining why custom is the right answer there — an assertion of toBe('custom') in this PR needs the reasoning attached or someone will "fix" it.

Added:

  • already demoted to custom{nav: custom}, overrides absent. Pins the no-op as deliberate.
  • stored with only one override — the case above.
  • picking custom leaves the overrides untouched — pins that Custom does not cascade.
  • Reload in the override test, renamed to persists the stored preset to the overrides. You were right that it only proved the in-memory store; with the repair now awaited inside load() it would have been meaningfully better anyway, but the reload makes it test what the fix claims.

On the endsWith nit: added expect(modeWrites).toContain('standard') alongside the not.toContain, so the URL matcher silently matching nothing fails loudly instead of passing vacuously. I kept endsWith rather than includes because storeSetting builds the path with encodeURIComponent(id) and no query string, so a substring match would be looser without being more correct — the positive assertion is what actually guards it.

Six tests now, all passing, and thanks for verifying the fixture seeding independently.

test.use({
initialSettings: { 'Comfy.Canvas.NavigationMode': 'standard' }
})

test('keeps the stored preset through load', async ({ comfyPage }) => {
expect(
await comfyPage.settings.getSetting('Comfy.Canvas.NavigationMode')
).toBe('standard')
})

// Reads the server, not the store: the migration is idempotent, so an
// in-memory read passes whether or not the write ever landed.
test('persists the stored preset to the overrides', async ({
comfyPage
}) => {
expect(
await comfyPage.settings.getPersistedSetting(
'Comfy.Canvas.LeftMouseClickBehavior'
)
).toBe('select')
expect(
await comfyPage.settings.getPersistedSetting(
'Comfy.Canvas.MouseWheelScroll'
)
).toBe('panning')
})
})

// Every profile that already loaded a 1.27.4+ build has the mode demoted to
// 'custom' with the overrides never written. The original choice is
// unrecoverable, so this pins the no-op as deliberate.
test.describe('already demoted to custom', () => {
test.use({
initialSettings: { 'Comfy.Canvas.NavigationMode': 'custom' }
})

test('is left as custom', async ({ comfyPage }) => {
expect(
await comfyPage.settings.getSetting('Comfy.Canvas.NavigationMode')
).toBe('custom')
})
})

test.describe('stored with only one override', () => {
test.use({
initialSettings: {
'Comfy.Canvas.NavigationMode': 'standard',
'Comfy.Canvas.MouseWheelScroll': 'zoom'
}
})

test('fills the gap without overwriting the stored override', async ({
comfyPage
}) => {
expect(
await comfyPage.settings.getSetting(
'Comfy.Canvas.LeftMouseClickBehavior'
)
).toBe('select')
expect(
await comfyPage.settings.getSetting('Comfy.Canvas.MouseWheelScroll')
).toBe('zoom')

// select + zoom is no preset, so demoting the mode is correct here.
// Overwriting the stored 'zoom' to match the mode instead would
// discard a real preference, which is the bug this all started as.
expect(
await comfyPage.settings.getSetting('Comfy.Canvas.NavigationMode')
).toBe('custom')
})
})
})

test.describe('Comfy.Canvas.LeftMouseClickBehavior', () => {
test('override to panning makes empty left-drag pan the canvas', async ({
comfyPage
Expand Down
49 changes: 49 additions & 0 deletions src/platform/settings/constants/canvasNavigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'

import { CANVAS_NAVIGATION_PRESETS } from '@/platform/settings/constants/canvasNavigation'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import type { SettingParams } from '@/platform/settings/types'

const NAV = 'Comfy.Canvas.NavigationMode'
const LEFT = 'Comfy.Canvas.LeftMouseClickBehavior'
const WHEEL = 'Comfy.Canvas.MouseWheelScroll'

const settingById = (id: string) => CORE_SETTINGS.find((s) => s.id === id)

const resolveDefaultValue = (setting: SettingParams | undefined): unknown => {
const { defaultValue } = setting ?? {}
return typeof defaultValue === 'function'
? (defaultValue as () => unknown)()
: defaultValue
}

const presetForMode = (mode: unknown) =>
typeof mode === 'string' ? CANVAS_NAVIGATION_PRESETS[mode] : undefined

const overrideDefaults = () => ({
[LEFT]: resolveDefaultValue(settingById(LEFT)),
[WHEEL]: resolveDefaultValue(settingById(WHEEL))
})

describe('CANVAS_NAVIGATION_PRESETS', () => {
/**
* The override defaults have to describe whichever Navigation Mode a fresh
* profile resolves to. If they disagree, that profile loads with a mode no
* preset matches and the override handlers demote it to 'custom' on first
* load — the bug this pairing exists to prevent. Asserted as a relationship
* rather than against fixed values so changing a default is what trips it.
*/
it('agrees with the default Navigation Mode', () => {
const defaultMode = resolveDefaultValue(settingById(NAV))

expect(presetForMode(defaultMode)).toEqual(overrideDefaults())
})

it('agrees with every install-versioned Navigation Mode default', () => {
const versioned = settingById(NAV)?.defaultsByInstallVersion ?? {}

for (const mode of Object.values(versioned)) {
expect(presetForMode(mode)).toEqual(overrideDefaults())
}
})
})
22 changes: 22 additions & 0 deletions src/platform/settings/constants/canvasNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Settings } from '@/schemas/apiSchema'

/**
* The Left Mouse Click Behavior and Mouse Wheel Scroll values each Navigation
* Mode preset stands for.
*
* `custom` is deliberately absent: it means "the overrides are whatever the
* user set", so there is no pair to apply or to restore from it.
*/
export const CANVAS_NAVIGATION_PRESETS: Record<
string,
Partial<Settings> | undefined
> = {
standard: {
'Comfy.Canvas.LeftMouseClickBehavior': 'select',
'Comfy.Canvas.MouseWheelScroll': 'panning'
},
legacy: {
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
'Comfy.Canvas.MouseWheelScroll': 'zoom'
}
}
20 changes: 5 additions & 15 deletions src/platform/settings/constants/coreSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from '@/locales/localeConfig'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
import { CANVAS_NAVIGATION_PRESETS } from '@/platform/settings/constants/canvasNavigation'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import type { ColorPalettes } from '@/schemas/colorPaletteSchema'
Expand Down Expand Up @@ -188,22 +189,11 @@ export const CORE_SETTINGS: SettingParams[] = [
'1.25.0': 'legacy'
},
onChange: async (val: unknown, old?: unknown) => {
const newValue = val as string
const oldValue = old as string | undefined
if (!oldValue) return
const settingStore = useSettingStore()
if (!old || typeof val !== 'string') return
const preset = CANVAS_NAVIGATION_PRESETS[val]
if (!preset) 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.

Profiles that were already bitten do not self-heal — the PR description says otherwise.

CANVAS_NAVIGATION_PRESETS has no custom entry, so if (!preset) return short-circuits for anyone whose stored mode is already custom.

But that is precisely the state the bug leaves people in. Cause 2 in the description writes custom to the server (settingStore.set('Comfy.Canvas.NavigationMode', 'custom') in LeftMouseClickBehavior.onChangeapi.storeSetting). So every user who has already loaded a 1.27.4+ build now has NavigationMode: "custom" persisted and the two overrides still absent — the demotion writes only the mode, never the overrides. After this PR those profiles load with mode custom, overrides falling back to panning/zoom, dropdown reading Custom, and useCanvasInteractions' isStandardNavMode still false. Nothing recovers them.

That is the population that filed FE-1503. The original choice is genuinely unrecoverable — the demotion overwrote the only record of it — so I am not asking for a fix here, but:

  • the claim "affected profiles self-heal on next load" holds only for profiles that have not yet loaded a 1.27.4+ build, which is a shrinking set. Please correct it in the description and in whatever goes into the release notes / the Linear issue, because as written it reads as "the reported bug is repaired for the reporters," and it is not — they have to re-pick the mode by hand (which will now stick).
  • worth adding the { 'Comfy.Canvas.NavigationMode': 'custom' } + no-overrides profile to the test matrix, to pin that it is a deliberate no-op rather than an untested gap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Do we need to get the profiles some DEET?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ha — taking that as a real question: do the already-bitten profiles get repaired, or do they stay itchy?

Partly, and I overstated it in the PR description. I probed every persisted shape against the real store and CORE_SETTINGS:

Persisted before load After load Repaired?
{nav: standard} — pre-1.27.4 standard + select/panning ✅ yes, this PR
{nav: custom, left: select, wheel: panning} custom + select/panning ❌ no — label still lies
{nav: custom} custom + panning/zoom ❌ no — choice is gone
{nav: custom, left: select, wheel: zoom} — real Custom untouched ✅ correctly left alone

So: this PR stops the bite, and it heals anyone who hadn't been bitten yet (row 1 — a preset stored before 1.27.4, which is the population that gets corrupted on their next load). It does not heal anyone already corrupted. Row 2 keeps reading Custom even though its overrides are exactly the Standard pair, which is precisely the state in the original report's screenshot. Row 3 is worse and unrecoverable — there is no record left that they ever chose Standard.

Recovery for rows 2 and 3 is one manual re-pick of the mode. That does work cleanly post-fix — a single write, since the overrides already match:

E: after user re-picks Standard
  writes: [["Comfy.Canvas.NavigationMode","standard"]]
  effective: {"nav":"standard","left":"select","wheel":"panning"}

I'd correct the PR description's "self-heals affected profiles on next load" to "self-heals profiles that had not yet been corrupted."

Could we DEET row 2 automatically? Yes — normalise custom to the matching preset at load when the two overrides exactly equal a preset pair. I deliberately didn't, because row 2 is byte-identical to someone who deliberately picked Custom and happened to set select+panning; there's no way to tell them apart. And it isn't a no-op: useCanvasInteractions gates on NavigationMode === 'standard' exactly, so flipping the label would switch wheel forwarding on for those users. That's a behaviour change for real Custom users, which felt like it deserved its own PR and its own decision rather than riding along in a bugfix. Row 4 is the case that has to keep working, and it does.

Happy to add that normalisation here if you'd rather have the retroactive fix — it's small, and it's arguably the right semantics given custom is really an output state (the dropdown offering it as an input is itself a bit odd). Just say the word. The longer-term version is the follow-up in the description: derive NavigationMode from the two overrides instead of storing a third key, which makes rows 2 and 3 impossible rather than repairable.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You are right, and I had reached the same conclusion independently just before your review landed — a reviewer asked whether the profiles needed "some DEET", so I probed every persisted shape against the real store. Results agreed with your reading exactly:

Persisted before load After load Repaired?
{nav: standard} — pre-1.27.4 standard + select/panning
{nav: custom, left: select, wheel: panning} custom + select/panning ❌ label still lies
{nav: custom} custom + panning/zoom ❌ choice is gone
{nav: custom, left: select, wheel: zoom} — real Custom untouched ✅ correctly

So the description's claim is wrong as written and I have corrected it: it holds only for profiles that have not yet loaded a 1.27.4+ build. Your framing that this is a shrinking set is the important part — it is worse than "some users are unaffected", because the reporters of FE-1503 are by definition in the already-demoted group. They have to re-pick by hand, which does now stick.

Both requests done:

  • Test added. already demoted to custom seeds {nav: custom} with the overrides absent and asserts it stays custom, so the no-op is pinned as deliberate rather than looking like an untested gap. I also added the mixed-pair profile, which is the case that proves the no-op is not just laziness.
  • Correcting the claim beyond this PR. I cannot edit the description with my tooling, so I am taking the correction to the team for the description, the release notes and the FE-1503 comment thread — agreed that "the reported bug is repaired for the reporters" is the wrong thing to let stand anywhere, since it is exactly what a triager would read and close on.

Agreed on not fixing it here. Auto-repairing row 2 would mean inferring the mode from a matching override pair, and row 2 is byte-identical to someone who deliberately picked Custom and happened to set select+panning; it is not cosmetic either, since useCanvasInteractions gates on NavigationMode === 'standard' exactly, so it would flip wheel forwarding on for real Custom users. Row 3 is unrecoverable regardless. The follow-up in the description — deriving the mode from the two overrides instead of storing a third key — makes rows 2 and 3 unreachable rather than repairable, which is the actual fix.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Follow-up to my earlier answer: the repair has moved since, and it now heals slightly more than it did.

@benjcooley's review pointed out that doing it from the onChange registration replay was the wrong hook — detached un-awaited write, silent dependence on CORE_SETTINGS ordering, and writes that skipped dispatchChange/telemetry. It now runs as migrateCanvasNavigationOverrides() from load(), next to the existing zoom-threshold migration.

The DEET table is unchanged in substance — already-demoted profiles still do not self-heal, and I have corrected that claim in the description thread — but the repair is now awaited and order-independent, and the previously-untested cases are pinned:

  • {nav: custom} + no overrides → stays custom (deliberate no-op, now covered)
  • {nav: standard, wheel: zoom} → fills left: select, keeps zoom, demotes to custom, because select+zoom is genuinely not a preset. That one failed when I first wrote it — my expectation was wrong, not the code.

Six E2E tests plus migration unit coverage, all green.


if (newValue === 'standard') {
await settingStore.setMany({
'Comfy.Canvas.LeftMouseClickBehavior': 'select',
'Comfy.Canvas.MouseWheelScroll': 'panning'
})
} else if (newValue === 'legacy') {
await settingStore.setMany({
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
'Comfy.Canvas.MouseWheelScroll': 'zoom'
})
}
await useSettingStore().setMany(preset)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
},
{
Expand Down
Loading
Loading