Skip to content

fix: stop Canvas Navigation mode resetting to Custom on reload - #14716

Merged
DrJKL merged 11 commits into
mainfrom
glary/fix-canvas-navigation-mode-reset
Aug 14, 2026
Merged

fix: stop Canvas Navigation mode resetting to Custom on reload#14716
DrJKL merged 11 commits into
mainfrom
glary/fix-canvas-navigation-mode-reset

Conversation

@DrJKL

@DrJKL DrJKL commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

PR Created by the Glary-Bot Agent


Fixes FE-1503.

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:

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:

Beforecomfy.settings.json silently rewritten to custom; dropdown reads Custom while the radios still show the Standard pair:

before

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

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 — 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

After: Navigation Mode retains Standard (New) across a reload, with Select and Panning consistent with it

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Canvas navigation presets are centralized. The setting store migrates missing navigation overrides and updates values before asynchronous change handlers run. Unit and browser tests cover preset persistence, legacy settings, custom modes, migration, and notification order.

Changes

Canvas navigation settings

Layer / File(s) Summary
Navigation preset contract
src/platform/settings/constants/canvasNavigation.ts, src/platform/settings/constants/canvasNavigation.test.ts
Standard and legacy presets define left-click and mouse-wheel settings. Tests compare the presets with current and install-versioned defaults.
Setting store migration and notification order
src/platform/settings/types.ts, src/platform/settings/settingStore.ts, src/platform/settings/settingStore.test.ts
Settings loading materializes missing navigation overrides. applySettingLocally, set, and setMany await asynchronous change handlers after updating in-memory values.
Canvas navigation mode application
src/platform/settings/constants/coreSettings.ts, browser_tests/tests/canvasSettings.spec.ts
Navigation mode changes apply recognized presets and avoid unintended custom writes. Browser tests cover standard, legacy, custom, and partial-override profiles.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score: 🔵 Low · up to 08e05

This change preserves the selected canvas navigation mode across reloads and keeps its dependent controls aligned. A bounded type-safety concern remains around preset lookup and default resolution, warranting owner follow-up but not blocking merge.

Suggested reviewers: christian-byrne, austinmroz, benceruleanlu

Sequence Diagram(s)

sequenceDiagram
  participant BrowserTest
  participant SettingStore
  participant CanvasNavigationMode
  participant CanvasSettings
  BrowserTest->>SettingStore: select navigation mode
  SettingStore->>CanvasNavigationMode: invoke onChange
  CanvasNavigationMode->>CanvasSettings: apply matching preset
  CanvasSettings-->>SettingStore: persist navigation settings
  SettingStore-->>BrowserTest: retain selected mode
Loading

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
End-To-End Regression Coverage For Fixes ❓ Inconclusive The changed-file list and PR description are available, but commit subjects are not; the check requires commit-subject evidence before evaluating the bug-fix condition. Provide the commit subjects, or confirm that no commit subject contains fix, fixed, fixes, fixing, bugfix, or hotfix.
✅ Passed checks (5 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the problem, fix, tests, verification results, screenshots, and follow-ups, although it does not use the repository template headings.
Title check ✅ Passed The title clearly and concisely summarizes the primary fix: preventing Canvas Navigation mode from resetting to Custom after reload.
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.
Adr Compliance For Entity/Litegraph Changes ✅ Passed The changed-file list contains only browser tests and platform settings files. It contains no litegraph, ECS, or graph-entity files, so this check does not apply.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch glary/fix-canvas-navigation-mode-reset

Comment @coderabbitai help to get the list of available commands.

@fennuck-bot

fennuck-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

@coderabbitai review

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🎭 Playwright: ✅ 1812 passed, 0 failed · 3 flaky

📊 Browser Reports
  • chromium: View Report (✅ 1791 / ❌ 0 / ⚠️ 3 / ⏭️ 5)
  • chromium-2x: View Report (✅ 2 / ❌ 0 / ⚠️ 0 / ⏭️ 0)
  • chromium-0.5x: View Report (✅ 1 / ❌ 0 / ⚠️ 0 / ⏭️ 0)
  • mobile-chrome: View Report (✅ 18 / ❌ 0 / ⚠️ 0 / ⏭️ 0)

🎨 Storybook: ✅ Built — View Storybook

Details

⏰ Completed at: 08/14/2026, 02:07:38 AM UTC

Links

📦 Bundle: 8.85 MB gzip 🔴 +1.44 kB

Details

Summary

  • Raw size: 37.3 MB baseline 37.3 MB — 🔴 +3.3 kB
  • Gzip: 8.85 MB baseline 8.85 MB — 🔴 +1.44 kB
  • Brotli: 6.19 MB baseline 6.18 MB — 🔴 +925 B
  • Bundles: 439 current • 439 baseline • 145 added / 145 removed

Category Glance
Data & Services 🔴 +3.52 kB (3.52 MB) · Graph Workspace 🟢 -227 B (1.37 MB) · Other 🔴 +1 B (14.2 MB) · Vendor & Third-Party ⚪ 0 B (16.8 MB) · Panels & Settings ⚪ 0 B (565 kB) · Utilities & Hooks ⚪ 0 B (550 kB) · + 5 more

App Entry Points — 3.71 kB (baseline 3.71 kB) • ⚪ 0 B

Main entry bundles and manifests

File Before After Δ Raw Δ Gzip Δ Brotli
assets/index-BGvsr143.js (removed) 3.71 kB 🟢 -3.71 kB 🟢 -1.86 kB 🟢 -1.62 kB
assets/index-Dbt2TYjW.js (new) 3.71 kB 🔴 +3.71 kB 🔴 +1.86 kB 🔴 +1.62 kB

Status: 1 added / 1 removed

Graph Workspace — 1.37 MB (baseline 1.37 MB) • 🟢 -227 B

Graph editor runtime, canvas, workflow orchestration

File Before After Δ Raw Δ Gzip Δ Brotli
assets/GraphView-C45OxBcW.js (removed) 1.36 MB 🟢 -1.36 MB 🟢 -297 kB 🟢 -223 kB
assets/GraphView-COzHei_f.js (new) 1.36 MB 🔴 +1.36 MB 🔴 +297 kB 🔴 +223 kB
assets/WidgetCompositor-BXYjpruR.js (new) 8.17 kB 🔴 +8.17 kB 🔴 +2.76 kB 🔴 +2.47 kB
assets/WidgetCompositor-DA4t-Qrq.js (removed) 8.17 kB 🟢 -8.17 kB 🟢 -2.76 kB 🟢 -2.45 kB

Status: 2 added / 2 removed / 1 unchanged

Views & Navigation — 124 kB (baseline 124 kB) • ⚪ 0 B

Top-level views, pages, and routed surfaces

File Before After Δ Raw Δ Gzip Δ Brotli
assets/CloudSurveyView-CB4SyuQM.js (removed) 25 kB 🟢 -25 kB 🟢 -6.24 kB 🟢 -5.52 kB
assets/CloudSurveyView-DnbiIySF.js (new) 25 kB 🔴 +25 kB 🔴 +6.24 kB 🔴 +5.52 kB
assets/CloudLayoutView--Fj2IzAy.js (removed) 21.8 kB 🟢 -21.8 kB 🟢 -6.57 kB 🟢 -5.75 kB
assets/CloudLayoutView-BZE08n_n.js (new) 21.8 kB 🔴 +21.8 kB 🔴 +6.57 kB 🔴 +5.75 kB
assets/UserCheckView-CfvF6E1b.js (removed) 8.75 kB 🟢 -8.75 kB 🟢 -2.19 kB 🟢 -1.91 kB
assets/UserCheckView-CUteGURZ.js (new) 8.75 kB 🔴 +8.75 kB 🔴 +2.19 kB 🔴 +1.91 kB
assets/CloudLoginView-B-LptWbc.js (new) 8.74 kB 🔴 +8.74 kB 🔴 +2.55 kB 🔴 +2.27 kB
assets/CloudLoginView-C5WT2CNI.js (removed) 8.74 kB 🟢 -8.74 kB 🟢 -2.55 kB 🟢 -2.26 kB
assets/useCloudAuthPage-B144t5dt.js (removed) 7.08 kB 🟢 -7.08 kB 🟢 -2.51 kB 🟢 -2.21 kB
assets/useCloudAuthPage-DQV89Vf0.js (new) 7.08 kB 🔴 +7.08 kB 🔴 +2.51 kB 🔴 +2.21 kB
assets/CloudSignupView-Bfx_OW2C.js (new) 6.96 kB 🔴 +6.96 kB 🔴 +2.28 kB 🔴 +2 kB
assets/CloudSignupView-DVyx_JYG.js (removed) 6.96 kB 🟢 -6.96 kB 🟢 -2.28 kB 🟢 -2 kB
assets/WidgetTextPreview-CiPGcm2h.js (new) 6.07 kB 🔴 +6.07 kB 🔴 +2.13 kB 🔴 +1.89 kB
assets/WidgetTextPreview-utz2bqGJ.js (removed) 6.07 kB 🟢 -6.07 kB 🟢 -2.13 kB 🟢 -1.89 kB
assets/CloudSubscriptionRedirectView-Bdg-Ki7l.js (new) 6.05 kB 🔴 +6.05 kB 🔴 +2.25 kB 🔴 +1.96 kB
assets/CloudSubscriptionRedirectView-DqOdTVBc.js (removed) 6.05 kB 🟢 -6.05 kB 🟢 -2.25 kB 🟢 -1.96 kB
assets/UserSelectView-CUXQjNnh.js (new) 5.49 kB 🔴 +5.49 kB 🔴 +1.96 kB 🔴 +1.71 kB
assets/UserSelectView-k_8W77Wg.js (removed) 5.49 kB 🟢 -5.49 kB 🟢 -1.96 kB 🟢 -1.72 kB
assets/CloudForgotPasswordView-Bc6Rm_Mc.js (new) 4.97 kB 🔴 +4.97 kB 🔴 +1.72 kB 🔴 +1.5 kB
assets/CloudForgotPasswordView-De5R7917.js (removed) 4.97 kB 🟢 -4.97 kB 🟢 -1.73 kB 🟢 -1.49 kB
assets/CloudAuthTimeoutView-BqrD3jNa.js (removed) 4.43 kB 🟢 -4.43 kB 🟢 -1.54 kB 🟢 -1.34 kB
assets/CloudAuthTimeoutView-DWkG00QA.js (new) 4.43 kB 🔴 +4.43 kB 🔴 +1.54 kB 🔴 +1.34 kB
assets/OAuthLayoutView-1TaecVO9.js (removed) 1.31 kB 🟢 -1.31 kB 🟢 -701 B 🟢 -586 B
assets/OAuthLayoutView-yiFqetsU.js (new) 1.31 kB 🔴 +1.31 kB 🔴 +699 B 🔴 +594 B
assets/WidgetTextPreview-BsaZ0-CF.js (new) 131 B 🔴 +131 B 🔴 +100 B 🔴 +89 B
assets/WidgetTextPreview-DjvNnKaT.js (removed) 131 B 🟢 -131 B 🟢 -100 B 🟢 -90 B

Status: 13 added / 13 removed / 4 unchanged

Panels & Settings — 565 kB (baseline 565 kB) • ⚪ 0 B

Configuration panels, inspectors, and settings screens

File Before After Δ Raw Δ Gzip Δ Brotli
assets/KeybindingPanel-B15kampW.js (removed) 49.4 kB 🟢 -49.4 kB 🟢 -9.95 kB 🟢 -8.79 kB
assets/KeybindingPanel-DslMIPlN.js (new) 49.4 kB 🔴 +49.4 kB 🔴 +9.95 kB 🔴 +8.8 kB
assets/SecretsPanel-B8WBcBSJ.js (new) 33.7 kB 🔴 +33.7 kB 🔴 +7.87 kB 🔴 +6.91 kB
assets/SecretsPanel-Bw9lF2d6.js (removed) 33.7 kB 🟢 -33.7 kB 🟢 -7.87 kB 🟢 -6.91 kB
assets/CreditsPanel-DwdCuiQi.js (new) 11.5 kB 🔴 +11.5 kB 🔴 +3.21 kB 🔴 +2.81 kB
assets/CreditsPanel-wSf0ow9v.js (removed) 11.5 kB 🟢 -11.5 kB 🟢 -3.21 kB 🟢 -2.81 kB
assets/AboutPanel-CxkFpHvL.js (new) 11.2 kB 🔴 +11.2 kB 🔴 +3.03 kB 🔴 +2.72 kB
assets/AboutPanel-DE0pm9oJ.js (removed) 11.2 kB 🟢 -11.2 kB 🟢 -3.03 kB 🟢 -2.72 kB
assets/ExtensionPanel-BUbaonC0.js (new) 9.19 kB 🔴 +9.19 kB 🔴 +2.51 kB 🔴 +2.23 kB
assets/ExtensionPanel-Bx5ZdQMX.js (removed) 9.19 kB 🟢 -9.19 kB 🟢 -2.51 kB 🟢 -2.23 kB
assets/ServerConfigPanel-_j5GKeNJ.js (new) 6.09 kB 🔴 +6.09 kB 🔴 +1.94 kB 🔴 +1.72 kB
assets/ServerConfigPanel-Dcnh0L7p.js (removed) 6.09 kB 🟢 -6.09 kB 🟢 -1.94 kB 🟢 -1.72 kB
assets/UserPanel-2rgdnzyv.js (new) 5.73 kB 🔴 +5.73 kB 🔴 +1.78 kB 🔴 +1.54 kB
assets/UserPanel-53dwwA5j.js (removed) 5.73 kB 🟢 -5.73 kB 🟢 -1.78 kB 🟢 -1.54 kB
assets/refreshRemoteConfig-CXScHQxc.js (removed) 3.44 kB 🟢 -3.44 kB 🟢 -1.33 kB 🟢 -1.18 kB
assets/refreshRemoteConfig-DHcgRAwg.js (new) 3.44 kB 🔴 +3.44 kB 🔴 +1.33 kB 🔴 +1.17 kB
assets/cloudRemoteConfig-CsMp8X51.js (removed) 951 B 🟢 -951 B 🟢 -515 B 🟢 -432 B
assets/cloudRemoteConfig-DiKvpHJ_.js (new) 951 B 🔴 +951 B 🔴 +516 B 🔴 +433 B
assets/refreshRemoteConfig-D3i6FT73.js (removed) 110 B 🟢 -110 B 🟢 -89 B 🟢 -85 B
assets/refreshRemoteConfig-DaOMfw3e.js (new) 110 B 🔴 +110 B 🔴 +89 B 🔴 +87 B

Status: 10 added / 10 removed / 16 unchanged

User & Accounts — 27.7 kB (baseline 27.7 kB) • ⚪ 0 B

Authentication, profile, and account management bundles

File Before After Δ Raw Δ Gzip Δ Brotli
assets/SignUpForm-Bhewl8xO.js (removed) 13.3 kB 🟢 -13.3 kB 🟢 -4.52 kB 🟢 -3.93 kB
assets/SignUpForm-DDYoNvBP.js (new) 13.3 kB 🔴 +13.3 kB 🔴 +4.52 kB 🔴 +3.93 kB
assets/auth-BWy7Pgkz.js (new) 3.71 kB 🔴 +3.71 kB 🔴 +1.28 kB 🔴 +1.1 kB
assets/auth-DywNHv-G.js (removed) 3.71 kB 🟢 -3.71 kB 🟢 -1.29 kB 🟢 -1.1 kB
assets/UpdatePasswordContent-Bq4e6ST6.js (removed) 1.85 kB 🟢 -1.85 kB 🟢 -842 B 🟢 -736 B
assets/UpdatePasswordContent-CGzfogei.js (new) 1.85 kB 🔴 +1.85 kB 🔴 +842 B 🔴 +736 B
assets/authStore-D75OoaLq.js (new) 128 B 🔴 +128 B 🔴 +107 B 🔴 +101 B
assets/authStore-Qz7x5SNn.js (removed) 128 B 🟢 -128 B 🟢 -107 B 🟢 -113 B
assets/workspaceAuthStore-BhLhAVZQ.js (new) 108 B 🔴 +108 B 🔴 +99 B 🔴 +104 B
assets/workspaceAuthStore-BtudY_Ev.js (removed) 108 B 🟢 -108 B 🟢 -99 B 🟢 -108 B
assets/auth-BHdCWvc0.js (removed) 105 B 🟢 -105 B 🟢 -96 B 🟢 -83 B
assets/auth-DycbHrqB.js (new) 105 B 🔴 +105 B 🔴 +96 B 🔴 +80 B

Status: 6 added / 6 removed / 5 unchanged

Editors & Dialogs — 125 kB (baseline 125 kB) • ⚪ 0 B

Modals, dialogs, drawers, and in-app editors

File Before After Δ Raw Δ Gzip Δ Brotli
assets/ComfyHubPublishDialog-CodkKzSA.js (removed) 90.1 kB 🟢 -90.1 kB 🟢 -19.3 kB 🟢 -16.5 kB
assets/ComfyHubPublishDialog-xM3ZmQY6.js (new) 90.1 kB 🔴 +90.1 kB 🔴 +19.3 kB 🔴 +16.5 kB
assets/useShareDialog-IGRfqfQA.js (new) 23.9 kB 🔴 +23.9 kB 🔴 +5.7 kB 🔴 +5.04 kB
assets/useShareDialog-loJtlIB0.js (removed) 23.9 kB 🟢 -23.9 kB 🟢 -5.7 kB 🟢 -5.04 kB
assets/feedbackDialog-CuI2IHu-.js (removed) 4.45 kB 🟢 -4.45 kB 🟢 -1.87 kB 🟢 -1.59 kB
assets/feedbackDialog-GPhCw8bi.js (new) 4.45 kB 🔴 +4.45 kB 🔴 +1.87 kB 🔴 +1.59 kB
assets/useRangeEditor-DMvWd6oH.js (removed) 3.29 kB 🟢 -3.29 kB 🟢 -1.14 kB 🟢 -1.03 kB
assets/useRangeEditor-qU4sDuKy.js (new) 3.29 kB 🔴 +3.29 kB 🔴 +1.14 kB 🔴 +1.03 kB
assets/useLayerEditor-Cerg-fBZ.js (removed) 1.01 kB 🟢 -1.01 kB 🟢 -491 B 🟢 -407 B
assets/useLayerEditor-ZsqcRXjS.js (new) 1.01 kB 🔴 +1.01 kB 🔴 +492 B 🔴 +405 B
assets/ComfyHubPublishDialog-B56cRzRa.js (removed) 143 B 🟢 -143 B 🟢 -105 B 🟢 -94 B
assets/ComfyHubPublishDialog-CVAkuDM0.js (new) 143 B 🔴 +143 B 🔴 +105 B 🔴 +96 B
assets/useSubscriptionDialog-DCf_j3hk.js (removed) 108 B 🟢 -108 B 🟢 -102 B 🟢 -90 B
assets/useSubscriptionDialog-dH0K4XPy.js (new) 108 B 🔴 +108 B 🔴 +102 B 🔴 +90 B

Status: 7 added / 7 removed / 1 unchanged

UI Components — 67.1 kB (baseline 67.1 kB) • ⚪ 0 B

Reusable component library chunks

File Before After Δ Raw Δ Gzip Δ Brotli
assets/ComfyQueueButton-BblvAtKv.js (new) 14.6 kB 🔴 +14.6 kB 🔴 +3.97 kB 🔴 +3.52 kB
assets/ComfyQueueButton-BsEOGPUv.js (removed) 14.6 kB 🟢 -14.6 kB 🟢 -3.97 kB 🟢 -3.52 kB
assets/useTerminalTabs-C5RRdzaZ.js (removed) 11.8 kB 🟢 -11.8 kB 🟢 -3.69 kB 🟢 -3.28 kB
assets/useTerminalTabs-DKIRzDJT.js (new) 11.8 kB 🔴 +11.8 kB 🔴 +3.69 kB 🔴 +3.27 kB
assets/InviteMembersForm-BYgOxIYd.js (new) 8.2 kB 🔴 +8.2 kB 🔴 +2.72 kB 🔴 +2.42 kB
assets/InviteMembersForm-CUOULu42.js (removed) 8.2 kB 🟢 -8.2 kB 🟢 -2.72 kB 🟢 -2.42 kB
assets/SubscribeButton-FA--6UfW.js (new) 2.15 kB 🔴 +2.15 kB 🔴 +970 B 🔴 +851 B
assets/SubscribeButton-MDKc-am7.js (removed) 2.15 kB 🟢 -2.15 kB 🟢 -970 B 🟢 -851 B
assets/cloudFeedbackTopbarButton-2EQ3YsDj.js (removed) 705 B 🟢 -705 B 🟢 -421 B 🟢 -359 B
assets/cloudFeedbackTopbarButton-Do8XiCPd.js (new) 705 B 🔴 +705 B 🔴 +423 B 🔴 +358 B
assets/ComfyQueueButton-D8f3KDqK.js (removed) 128 B 🟢 -128 B 🟢 -99 B 🟢 -97 B
assets/ComfyQueueButton-HZIf8mYh.js (new) 128 B 🔴 +128 B 🔴 +99 B 🔴 +91 B

Status: 6 added / 6 removed / 8 unchanged

Data & Services — 3.52 MB (baseline 3.52 MB) • 🔴 +3.52 kB

Stores, services, APIs, and repositories

File Before After Δ Raw Δ Gzip Δ Brotli
assets/settingStore-B09HNuHa.js (new) 3.24 MB 🔴 +3.24 MB 🔴 +752 kB 🔴 +566 kB
assets/settingStore-BBIxPpP6.js (removed) 3.24 MB 🟢 -3.24 MB 🟢 -751 kB 🟢 -565 kB
assets/load3dService-B5iYowPM.js (new) 132 kB 🔴 +132 kB 🔴 +29.3 kB 🔴 +24.6 kB
assets/load3dService-gInGF4o_.js (removed) 132 kB 🟢 -132 kB 🟢 -29.3 kB 🟢 -24.6 kB
assets/api-BUQKl-iS.js (new) 98.4 kB 🔴 +98.4 kB 🔴 +27.3 kB 🔴 +23.4 kB
assets/api-thoXZjG6.js (removed) 98.4 kB 🟢 -98.4 kB 🟢 -27.3 kB 🟢 -23.4 kB
assets/workflowShareService-ByrtGa_y.js (new) 16.5 kB 🔴 +16.5 kB 🔴 +4.91 kB 🔴 +4.35 kB
assets/workflowShareService-kcjz5I5v.js (removed) 16.5 kB 🟢 -16.5 kB 🟢 -4.92 kB 🟢 -4.35 kB
assets/keybindingService-Cq19iH8r.js (new) 6.89 kB 🔴 +6.89 kB 🔴 +1.73 kB 🔴 +1.5 kB
assets/keybindingService-DrwS11ze.js (removed) 6.89 kB 🟢 -6.89 kB 🟢 -1.73 kB 🟢 -1.5 kB
assets/releaseStore-HOThMpZm.js (removed) 6.72 kB 🟢 -6.72 kB 🟢 -2.03 kB 🟢 -1.77 kB
assets/releaseStore-Vuwm69p4.js (new) 6.72 kB 🔴 +6.72 kB 🔴 +2.03 kB 🔴 +1.77 kB
assets/systemStatsStore-CTNvaG-a.js (new) 4.93 kB 🔴 +4.93 kB 🔴 +1.74 kB 🔴 +1.47 kB
assets/systemStatsStore-CZUt8q0-.js (removed) 4.93 kB 🟢 -4.93 kB 🟢 -1.74 kB 🟢 -1.47 kB
assets/userStore-D4ArKdiI.js (removed) 2.38 kB 🟢 -2.38 kB 🟢 -899 B 🟢 -796 B
assets/userStore-P1xOq_2j.js (new) 2.38 kB 🔴 +2.38 kB 🔴 +897 B 🔴 +795 B
assets/audioService-c1kCdXYf.js (removed) 1.71 kB 🟢 -1.71 kB 🟢 -831 B 🟢 -722 B
assets/audioService-DxkGYR69.js (new) 1.71 kB 🔴 +1.71 kB 🔴 +830 B 🔴 +723 B
assets/dialogService-D_0sudco.js (new) 98 B 🔴 +98 B 🔴 +97 B 🔴 +82 B
assets/dialogService-DTNDazre.js (removed) 98 B 🟢 -98 B 🟢 -97 B 🟢 -86 B
assets/releaseStore-CjNWJaKi.js (new) 95 B 🔴 +95 B 🔴 +86 B 🔴 +79 B
assets/releaseStore-DPYoiZLv.js (removed) 95 B 🟢 -95 B 🟢 -86 B 🟢 -90 B
assets/settingStore-Bna3D1W8.js (removed) 95 B 🟢 -95 B 🟢 -86 B 🟢 -81 B
assets/settingStore-xCX2_Ik3.js (new) 95 B 🔴 +95 B 🔴 +86 B 🔴 +86 B
assets/assetsStore-Dtnss7BS.js (removed) 94 B 🟢 -94 B 🟢 -92 B 🟢 -82 B
assets/assetsStore-j0gvV_gx.js (new) 94 B 🔴 +94 B 🔴 +92 B 🔴 +83 B
assets/api-CBTI20wJ.js (removed) 62 B 🟢 -62 B 🟢 -74 B 🟢 -66 B
assets/api-CR0pfRce.js (new) 62 B 🔴 +62 B 🔴 +74 B 🔴 +66 B

Status: 14 added / 14 removed / 3 unchanged

Utilities & Hooks — 550 kB (baseline 550 kB) • ⚪ 0 B

Helpers, composables, and utility bundles

File Before After Δ Raw Δ Gzip Δ Brotli
assets/useConflictDetection-CTrZH6Cm.js (removed) 236 kB 🟢 -236 kB 🟢 -53 kB 🟢 -43.2 kB
assets/useConflictDetection-FA5Y6tRy.js (new) 236 kB 🔴 +236 kB 🔴 +53 kB 🔴 +43.2 kB
assets/useLayerEditorSession-BN8HzVnE.js (removed) 158 kB 🟢 -158 kB 🟢 -40.8 kB 🟢 -34.3 kB
assets/useLayerEditorSession-CSAwLcq4.js (new) 158 kB 🔴 +158 kB 🔴 +40.8 kB 🔴 +34.3 kB
assets/useLoad3d-CkWXU3UX.js (new) 25.8 kB 🔴 +25.8 kB 🔴 +5.8 kB 🔴 +5.14 kB
assets/useLoad3d-CYkXat1A.js (removed) 25.8 kB 🟢 -25.8 kB 🟢 -5.8 kB 🟢 -5.15 kB
assets/useLoad3dViewer-AXyFOWM4.js (new) 21.2 kB 🔴 +21.2 kB 🔴 +4.98 kB 🔴 +4.38 kB
assets/useLoad3dViewer-P04logHW.js (removed) 21.2 kB 🟢 -21.2 kB 🟢 -4.98 kB 🟢 -4.36 kB
assets/useImageCrop-B_NbR3pb.js (removed) 14.9 kB 🟢 -14.9 kB 🟢 -3.42 kB 🟢 -2.99 kB
assets/useImageCrop-BNpaXzfq.js (new) 14.9 kB 🔴 +14.9 kB 🔴 +3.42 kB 🔴 +2.98 kB
assets/useDowngradeToPersonal-CMQRdCPB.js (removed) 10.9 kB 🟢 -10.9 kB 🟢 -2.75 kB 🟢 -2.36 kB
assets/useDowngradeToPersonal-xoWgSQqe.js (new) 10.9 kB 🔴 +10.9 kB 🔴 +2.75 kB 🔴 +2.36 kB
assets/useFeatureFlags-DfMgxEoZ.js (new) 6.99 kB 🔴 +6.99 kB 🔴 +2 kB 🔴 +1.72 kB
assets/useFeatureFlags-DoL7x2fF.js (removed) 6.99 kB 🟢 -6.99 kB 🟢 -2 kB 🟢 -1.7 kB
assets/useCompositorLayers-Dalw2a5h.js (removed) 2.94 kB 🟢 -2.94 kB 🟢 -898 B 🟢 -805 B
assets/useCompositorLayers-Dzq20yk9.js (new) 2.94 kB 🔴 +2.94 kB 🔴 +898 B 🔴 +804 B
assets/assetPreviewUtil-BxPWBPbH.js (new) 2.35 kB 🔴 +2.35 kB 🔴 +969 B 🔴 +845 B
assets/assetPreviewUtil-ClRYSP4m.js (removed) 2.35 kB 🟢 -2.35 kB 🟢 -967 B 🟢 -847 B
assets/useUpstreamValue-BrELCIe8.js (new) 1.99 kB 🔴 +1.99 kB 🔴 +758 B 🔴 +685 B
assets/useUpstreamValue-h54uILaz.js (removed) 1.99 kB 🟢 -1.99 kB 🟢 -758 B 🟢 -671 B
assets/useWorkspaceTierLabel-57MBq8NW.js (removed) 1.93 kB 🟢 -1.93 kB 🟢 -813 B 🟢 -695 B
assets/useWorkspaceTierLabel-oqoqG-f6.js (new) 1.93 kB 🔴 +1.93 kB 🔴 +813 B 🔴 +698 B
assets/subscriptionCheckoutUtil-BuiJdcKG.js (new) 877 B 🔴 +877 B 🔴 +522 B 🔴 +438 B
assets/subscriptionCheckoutUtil-CbxTLg6H.js (removed) 877 B 🟢 -877 B 🟢 -523 B 🟢 -454 B
assets/useSessionCookie-CbGVVv-B.js (removed) 652 B 🟢 -652 B 🟢 -335 B 🟢 -293 B
assets/useSessionCookie-NdWAxARL.js (new) 652 B 🔴 +652 B 🔴 +337 B 🔴 +290 B
assets/useLoad3d-BzBboQlk.js (removed) 311 B 🟢 -311 B 🟢 -164 B 🟢 -147 B
assets/useLoad3d-ChAbHG4P.js (new) 311 B 🔴 +311 B 🔴 +163 B 🔴 +147 B
assets/useSessionCookie-B_1NtQie.js (new) 101 B 🔴 +101 B 🔴 +86 B 🔴 +84 B
assets/useSessionCookie-CXKkXYTP.js (removed) 101 B 🟢 -101 B 🟢 -86 B 🟢 -76 B
assets/useFeatureFlags-6IqKgh5y.js (removed) 98 B 🟢 -98 B 🟢 -85 B 🟢 -81 B
assets/useFeatureFlags-CyY_FXG6.js (new) 98 B 🔴 +98 B 🔴 +85 B 🔴 +83 B
assets/useLoad3dViewer-BAhIP2wq.js (removed) 98 B 🟢 -98 B 🟢 -85 B 🟢 -87 B
assets/useLoad3dViewer-C1HqElOJ.js (new) 98 B 🔴 +98 B 🔴 +85 B 🔴 +85 B
assets/useCurrentUser-Bpnf-5jU.js (removed) 94 B 🟢 -94 B 🟢 -95 B 🟢 -87 B
assets/useCurrentUser-gIjw4bDU.js (new) 94 B 🔴 +94 B 🔴 +95 B 🔴 +95 B

Status: 18 added / 18 removed / 20 unchanged

Vendor & Third-Party — 16.8 MB (baseline 16.8 MB) • ⚪ 0 B

External libraries and shared vendor chunks

Status: 18 unchanged

Other — 14.2 MB (baseline 14.2 MB) • 🔴 +1 B

Bundles that do not match a named category

File Before After Δ Raw Δ Gzip Δ Brotli
assets/core-D5IJjnge.js (new) 115 kB 🔴 +115 kB 🔴 +29.7 kB 🔴 +25.1 kB
assets/core-zfO83g7v.js (removed) 115 kB 🟢 -115 kB 🟢 -29.7 kB 🟢 -25.1 kB
assets/WidgetSelect-COu_56nq.js (removed) 88.8 kB 🟢 -88.8 kB 🟢 -20.1 kB 🟢 -17.2 kB
assets/WidgetSelect-iOvfGB54.js (new) 88.8 kB 🔴 +88.8 kB 🔴 +20.1 kB 🔴 +17.2 kB
assets/SubscriptionPanelContentWorkspace-B9elmDK0.js (removed) 80 kB 🟢 -80 kB 🟢 -15.8 kB 🟢 -13.6 kB
assets/SubscriptionPanelContentWorkspace-Dg87JVve.js (new) 80 kB 🔴 +80 kB 🔴 +15.8 kB 🔴 +13.6 kB
assets/Load3D-C4roOVYn.js (new) 71.3 kB 🔴 +71.3 kB 🔴 +11.7 kB 🔴 +9.98 kB
assets/Load3D-Dl7oQOB3.js (removed) 71.3 kB 🟢 -71.3 kB 🟢 -11.7 kB 🟢 -9.97 kB
assets/WidgetVideoEdit-Da9EzQT3.js (new) 67.5 kB 🔴 +67.5 kB 🔴 +15.7 kB 🔴 +13.9 kB
assets/WidgetVideoEdit-KoX73vKo.js (removed) 67.5 kB 🟢 -67.5 kB 🟢 -15.7 kB 🟢 -13.9 kB
assets/SubscriptionTransitionPreviewWorkspace-BFUtFQJG.js (removed) 66.5 kB 🟢 -66.5 kB 🟢 -13.4 kB 🟢 -11.7 kB
assets/SubscriptionTransitionPreviewWorkspace-BwUmZoPm.js (new) 66.5 kB 🔴 +66.5 kB 🔴 +13.4 kB 🔴 +11.7 kB
assets/WorkspaceSettingsPanelContent-CtKQIT-h.js (removed) 58.2 kB 🟢 -58.2 kB 🟢 -12.4 kB 🟢 -10.8 kB
assets/WorkspaceSettingsPanelContent-CV5_jcWD.js (new) 58.2 kB 🔴 +58.2 kB 🔴 +12.4 kB 🔴 +10.8 kB
assets/Preview3d-C4Q7iV7A.js (new) 50.9 kB 🔴 +50.9 kB 🔴 +8.32 kB 🔴 +7.24 kB
assets/Preview3d-GEA_FjvP.js (removed) 50.9 kB 🟢 -50.9 kB 🟢 -8.32 kB 🟢 -7.25 kB
assets/main-BH775nEt.js (new) 45.3 kB 🔴 +45.3 kB 🔴 +13.2 kB 🔴 +11.4 kB
assets/main-DCRaYvkz.js (removed) 45.3 kB 🟢 -45.3 kB 🟢 -13.2 kB 🟢 -11.4 kB
assets/SubscriptionRequiredDialogContentUnified-7Ge3RIqN.js (new) 42.6 kB 🔴 +42.6 kB 🔴 +9.36 kB 🔴 +8.16 kB
assets/SubscriptionRequiredDialogContentUnified-Cpo-sUJp.js (removed) 42.6 kB 🟢 -42.6 kB 🟢 -9.36 kB 🟢 -8.17 kB
assets/LayerEditorContent-CbT_szuG.js (removed) 42.5 kB 🟢 -42.5 kB 🟢 -9.62 kB 🟢 -8.43 kB
assets/LayerEditorContent-jLqyws9H.js (new) 42.5 kB 🔴 +42.5 kB 🔴 +9.62 kB 🔴 +8.43 kB
assets/WidgetBoundingBoxes-D_kA7UEZ.js (new) 33.7 kB 🔴 +33.7 kB 🔴 +9.16 kB 🔴 +8.13 kB
assets/WidgetBoundingBoxes-DyfRUArs.js (removed) 33.7 kB 🟢 -33.7 kB 🟢 -9.16 kB 🟢 -8.13 kB
assets/WidgetPainter-B_64UHEh.js (removed) 32.6 kB 🟢 -32.6 kB 🟢 -7.87 kB 🟢 -6.97 kB
assets/WidgetPainter-DwsBgBZ2.js (new) 32.6 kB 🔴 +32.6 kB 🔴 +7.88 kB 🔴 +6.97 kB
assets/Load3dViewerContent-BsIO5e6Q.js (new) 30.8 kB 🔴 +30.8 kB 🔴 +6.28 kB 🔴 +5.45 kB
assets/Load3dViewerContent-DoBTNC2X.js (removed) 30.8 kB 🟢 -30.8 kB 🟢 -6.28 kB 🟢 -5.46 kB
assets/SubscriptionRequiredDialogContent-BjH9xV9F.js (removed) 26.9 kB 🟢 -26.9 kB 🟢 -6.36 kB 🟢 -5.61 kB
assets/SubscriptionRequiredDialogContent-DgKhtk0x.js (new) 26.9 kB 🔴 +26.9 kB 🔴 +6.36 kB 🔴 +5.61 kB
assets/SubscriptionRequiredDialogContentWorkspace-B7gNl1QS.js (removed) 25.1 kB 🟢 -25.1 kB 🟢 -5.79 kB 🟢 -5.1 kB
assets/SubscriptionRequiredDialogContentWorkspace-DjgQRGUV.js (new) 25.1 kB 🔴 +25.1 kB 🔴 +5.79 kB 🔴 +5.09 kB
assets/CreditsTile-BGq8OuUE.js (new) 24.9 kB 🔴 +24.9 kB 🔴 +6.65 kB 🔴 +5.83 kB
assets/CreditsTile-BQutdcXB.js (removed) 24.9 kB 🟢 -24.9 kB 🟢 -6.65 kB 🟢 -5.84 kB
assets/load3d-BBvb006w.js (removed) 22.2 kB 🟢 -22.2 kB 🟢 -5.4 kB 🟢 -4.67 kB
assets/load3d-DAeEaJ01.js (new) 22.2 kB 🔴 +22.2 kB 🔴 +5.4 kB 🔴 +4.67 kB
assets/CurrentUserPopoverWorkspace-BdT2sB7Q.js (removed) 21.5 kB 🟢 -21.5 kB 🟢 -4.85 kB 🟢 -4.32 kB
assets/CurrentUserPopoverWorkspace-C8hsdr4_.js (new) 21.5 kB 🔴 +21.5 kB 🔴 +4.85 kB 🔴 +4.32 kB
assets/SignInContent-BdkcHNUZ.js (removed) 20.6 kB 🟢 -20.6 kB 🟢 -5.18 kB 🟢 -4.54 kB
assets/SignInContent-BmeXyXib.js (new) 20.6 kB 🔴 +20.6 kB 🔴 +5.18 kB 🔴 +4.54 kB
assets/WidgetRecordAudio-C5Bb7ptD.js (removed) 16.6 kB 🟢 -16.6 kB 🟢 -4.6 kB 🟢 -4.11 kB
assets/WidgetRecordAudio-TixuOyww.js (new) 16.6 kB 🔴 +16.6 kB 🔴 +4.6 kB 🔴 +4.11 kB
assets/WidgetInputNumber-BlfVgQ45.js (new) 13.9 kB 🔴 +13.9 kB 🔴 +3.63 kB 🔴 +3.21 kB
assets/WidgetInputNumber-BNu0zw4X.js (removed) 13.9 kB 🟢 -13.9 kB 🟢 -3.63 kB 🟢 -3.21 kB
assets/WidgetRange-DMQeTm5b.js (removed) 13.7 kB 🟢 -13.7 kB 🟢 -3.55 kB 🟢 -3.13 kB
assets/WidgetRange-GdgPAYyy.js (new) 13.7 kB 🔴 +13.7 kB 🔴 +3.55 kB 🔴 +3.13 kB
assets/WaveAudioPlayer-BQDQG-vX.js (new) 12.8 kB 🔴 +12.8 kB 🔴 +3.46 kB 🔴 +3.05 kB
assets/WaveAudioPlayer-Ch3KJ3Sj.js (removed) 12.8 kB 🟢 -12.8 kB 🟢 -3.46 kB 🟢 -3.04 kB
assets/WidgetCurve-BQSwo1N_.js (removed) 11.2 kB 🟢 -11.2 kB 🟢 -3.48 kB 🟢 -3.15 kB
assets/WidgetCurve-tmRpGaPi.js (new) 11.2 kB 🔴 +11.2 kB 🔴 +3.47 kB 🔴 +3.15 kB
assets/TeamWorkspacesDialogContent-9FfcvfHS.js (new) 10.3 kB 🔴 +10.3 kB 🔴 +2.97 kB 🔴 +2.63 kB
assets/TeamWorkspacesDialogContent-CLtyh9GS.js (removed) 10.3 kB 🟢 -10.3 kB 🟢 -2.97 kB 🟢 -2.63 kB
assets/onboardingCloudRoutes-FzH9qIeM.js (new) 9.39 kB 🔴 +9.39 kB 🔴 +2.83 kB 🔴 +2.42 kB
assets/onboardingCloudRoutes-Ppr7AcgT.js (removed) 9.39 kB 🟢 -9.39 kB 🟢 -2.85 kB 🟢 -2.46 kB
assets/Load3DConfiguration-CySfQ3P1.js (removed) 8.91 kB 🟢 -8.91 kB 🟢 -2.61 kB 🟢 -2.3 kB
assets/Load3DConfiguration-u9ImzRc6.js (new) 8.91 kB 🔴 +8.91 kB 🔴 +2.61 kB 🔴 +2.3 kB
assets/WidgetImageCrop-3ap3OWfi.js (new) 8.49 kB 🔴 +8.49 kB 🔴 +2.66 kB 🔴 +2.34 kB
assets/WidgetImageCrop-BxfOdj0E.js (removed) 8.49 kB 🟢 -8.49 kB 🟢 -2.66 kB 🟢 -2.34 kB
assets/SetMemberCreditLimitDialogContent-CkhvRRPh.js (removed) 8.47 kB 🟢 -8.47 kB 🟢 -2.34 kB 🟢 -2.05 kB
assets/SetMemberCreditLimitDialogContent-gZOkMKSh.js (new) 8.47 kB 🔴 +8.47 kB 🔴 +2.34 kB 🔴 +2.05 kB
assets/nodeTemplates-00FsZxBy.js (new) 8.32 kB 🔴 +8.32 kB 🔴 +2.86 kB 🔴 +2.5 kB
assets/nodeTemplates-DSjVzllX.js (removed) 8.32 kB 🟢 -8.32 kB 🟢 -2.85 kB 🟢 -2.5 kB
assets/NightlySurveyController-HoH96x_E.js (removed) 7.5 kB 🟢 -7.5 kB 🟢 -2.55 kB 🟢 -2.25 kB
assets/NightlySurveyController-HvN_wYjU.js (new) 7.5 kB 🔴 +7.5 kB 🔴 +2.55 kB 🔴 +2.25 kB
assets/CloudRunButtonWrapper-cfNozjPP.js (removed) 6.84 kB 🟢 -6.84 kB 🟢 -2.18 kB 🟢 -1.91 kB
assets/CloudRunButtonWrapper-CT3u-knR.js (new) 6.84 kB 🔴 +6.84 kB 🔴 +2.18 kB 🔴 +1.91 kB
assets/WidgetWithControl-D2UuB1IZ.js (removed) 6.44 kB 🟢 -6.44 kB 🟢 -2.63 kB 🟢 -2.31 kB
assets/WidgetWithControl-D4sX7iZ5.js (new) 6.44 kB 🔴 +6.44 kB 🔴 +2.63 kB 🔴 +2.31 kB
assets/missingModelMetadata-CdIIIZq6.js (removed) 6.17 kB 🟢 -6.17 kB 🟢 -2.13 kB 🟢 -1.86 kB
assets/missingModelMetadata-rO5h8AfO.js (new) 6.17 kB 🔴 +6.17 kB 🔴 +2.13 kB 🔴 +1.86 kB
assets/CancelSubscriptionDialogContent-B_d75iVG.js (new) 5.97 kB 🔴 +5.97 kB 🔴 +1.98 kB 🔴 +1.75 kB
assets/CancelSubscriptionDialogContent-ws-GMdnl.js (removed) 5.97 kB 🟢 -5.97 kB 🟢 -1.98 kB 🟢 -1.75 kB
assets/load3dPreviewExtensions-32xOp8zM.js (new) 5.88 kB 🔴 +5.88 kB 🔴 +1.81 kB 🔴 +1.6 kB
assets/load3dPreviewExtensions-D_CtRbIs.js (removed) 5.88 kB 🟢 -5.88 kB 🟢 -1.81 kB 🟢 -1.6 kB
assets/launchCancellationFlow-Bmx1u7E3.js (new) 5.18 kB 🔴 +5.18 kB 🔴 +1.78 kB 🔴 +1.57 kB
assets/launchCancellationFlow-N9XRehP2.js (removed) 5.18 kB 🟢 -5.18 kB 🟢 -1.78 kB 🟢 -1.56 kB
assets/CreateWorkspaceDialogContent-BlkWEnl3.js (new) 5.12 kB 🔴 +5.12 kB 🔴 +1.79 kB 🔴 +1.55 kB
assets/CreateWorkspaceDialogContent-DEse23tP.js (removed) 5.12 kB 🟢 -5.12 kB 🟢 -1.79 kB 🟢 -1.55 kB
assets/ChangeMemberRoleDialogContent-BfOUjcps.js (removed) 4.97 kB 🟢 -4.97 kB 🟢 -1.64 kB 🟢 -1.43 kB
assets/ChangeMemberRoleDialogContent-C0qf_e6D.js (new) 4.97 kB 🔴 +4.97 kB 🔴 +1.64 kB 🔴 +1.43 kB
assets/InviteMemberDialogContent-B0umo_W3.js (removed) 4.96 kB 🟢 -4.96 kB 🟢 -1.64 kB 🟢 -1.44 kB
assets/InviteMemberDialogContent-D1b3FhAv.js (new) 4.96 kB 🔴 +4.96 kB 🔴 +1.64 kB 🔴 +1.44 kB
assets/EditWorkspaceDialogContent-cWr6loA4.js (removed) 4.93 kB 🟢 -4.93 kB 🟢 -1.76 kB 🟢 -1.52 kB
assets/EditWorkspaceDialogContent-IFU81fKm.js (new) 4.93 kB 🔴 +4.93 kB 🔴 +1.76 kB 🔴 +1.53 kB
assets/WidgetTextarea-B9iZh6ZN.js (new) 4.81 kB 🔴 +4.81 kB 🔴 +1.87 kB 🔴 +1.64 kB
assets/WidgetTextarea-BxXdMR7Z.js (removed) 4.81 kB 🟢 -4.81 kB 🟢 -1.87 kB 🟢 -1.65 kB
assets/saveMesh-C4WJ7lIv.js (removed) 4.76 kB 🟢 -4.76 kB 🟢 -1.52 kB 🟢 -1.34 kB
assets/saveMesh-DF8wP92R.js (new) 4.76 kB 🔴 +4.76 kB 🔴 +1.52 kB 🔴 +1.34 kB
assets/WorkspacePanelContent-D4PP7ago.js (removed) 4.74 kB 🟢 -4.74 kB 🟢 -1.62 kB 🟢 -1.44 kB
assets/WorkspacePanelContent-p3FB7cBD.js (new) 4.74 kB 🔴 +4.74 kB 🔴 +1.63 kB 🔴 +1.44 kB
assets/ValueControlPopover-BbIVPLPR.js (new) 4.49 kB 🔴 +4.49 kB 🔴 +1.55 kB 🔴 +1.38 kB
assets/ValueControlPopover-ckVP4E9E.js (removed) 4.49 kB 🟢 -4.49 kB 🟢 -1.55 kB 🟢 -1.38 kB
assets/DeleteWorkspaceDialogContent-B0I4Jg6i.js (new) 3.84 kB 🔴 +3.84 kB 🔴 +1.44 kB 🔴 +1.24 kB
assets/DeleteWorkspaceDialogContent-BBBpampA.js (removed) 3.84 kB 🟢 -3.84 kB 🟢 -1.44 kB 🟢 -1.24 kB
assets/RemoveMemberDialogContent-CrAZ1QSV.js (removed) 3.76 kB 🟢 -3.76 kB 🟢 -1.39 kB 🟢 -1.21 kB
assets/RemoveMemberDialogContent-DLNVpjR9.js (new) 3.76 kB 🔴 +3.76 kB 🔴 +1.39 kB 🔴 +1.2 kB
assets/RevokeInviteDialogContent-BL9V3XoA.js (new) 3.67 kB 🔴 +3.67 kB 🔴 +1.39 kB 🔴 +1.22 kB
assets/RevokeInviteDialogContent-BlvH7o49.js (removed) 3.67 kB 🟢 -3.67 kB 🟢 -1.39 kB 🟢 -1.21 kB
assets/LeaveWorkspaceDialogContent-B_NvwYab.js (removed) 3.67 kB 🟢 -3.67 kB 🟢 -1.39 kB 🟢 -1.22 kB
assets/LeaveWorkspaceDialogContent-CXzlErOE.js (new) 3.67 kB 🔴 +3.67 kB 🔴 +1.39 kB 🔴 +1.22 kB
assets/InviteMemberUpsellDialogContent-Bk6gjDmf.js (new) 3.47 kB 🔴 +3.47 kB 🔴 +1.24 kB 🔴 +1.09 kB
assets/InviteMemberUpsellDialogContent-DXvwZWA2.js (removed) 3.47 kB 🟢 -3.47 kB 🟢 -1.24 kB 🟢 -1.09 kB
assets/workspaceCheckoutTelemetry-DU6GndaX.js (new) 3.4 kB 🔴 +3.4 kB 🔴 +1.52 kB 🔴 +1.32 kB
assets/workspaceCheckoutTelemetry-moXj7_60.js (removed) 3.4 kB 🟢 -3.4 kB 🟢 -1.52 kB 🟢 -1.33 kB
assets/GlobalToast-DnxtE3MM.js (removed) 3.25 kB 🟢 -3.25 kB 🟢 -1.3 kB 🟢 -1.12 kB
assets/GlobalToast-Dyh6MFpp.js (new) 3.25 kB 🔴 +3.25 kB 🔴 +1.3 kB 🔴 +1.11 kB
assets/Media3DTop-Dc__8WfO.js (new) 3.21 kB 🔴 +3.21 kB 🔴 +1.26 kB 🔴 +1.11 kB
assets/Media3DTop-OUybZf1g.js (removed) 3.21 kB 🟢 -3.21 kB 🟢 -1.26 kB 🟢 -1.11 kB
assets/load3dAdvanced-Bo_LYwCh.js (new) 2.82 kB 🔴 +2.82 kB 🔴 +1.1 kB 🔴 +956 B
assets/load3dAdvanced-xjfGMhZh.js (removed) 2.82 kB 🟢 -2.82 kB 🟢 -1.09 kB 🟢 -957 B
assets/SubscribeToRun-BexzZIV5.js (removed) 2.39 kB 🟢 -2.39 kB 🟢 -1.03 kB 🟢 -909 B
assets/SubscribeToRun-DnIOGnlK.js (new) 2.39 kB 🔴 +2.39 kB 🔴 +1.03 kB 🔴 +908 B
assets/MediaAudioTop-CxRQYxvw.js (new) 1.62 kB 🔴 +1.62 kB 🔴 +807 B 🔴 +669 B
assets/MediaAudioTop-DoYPbjKz.js (removed) 1.62 kB 🟢 -1.62 kB 🟢 -808 B 🟢 -673 B
assets/cloudSessionCookie-C0gFJdsA.js (removed) 933 B 🟢 -933 B 🟢 -433 B 🟢 -378 B
assets/cloudSessionCookie-DUKvyuEY.js (new) 933 B 🔴 +933 B 🔴 +431 B 🔴 +377 B
assets/cloudBadges-BqeoJ1JW.js (new) 922 B 🔴 +922 B 🔴 +516 B 🔴 +435 B
assets/cloudBadges-CDwK7Nw-.js (removed) 922 B 🟢 -922 B 🟢 -516 B 🟢 -469 B
assets/Load3DAdvanced-DdWqYA0Z.js (new) 761 B 🔴 +761 B 🔴 +423 B 🔴 +358 B
assets/Load3DAdvanced-DN2ZwwsR.js (removed) 761 B 🟢 -761 B 🟢 -423 B 🟢 -359 B
assets/nightlyBadges-4kbVyPS6.js (removed) 411 B 🟢 -411 B 🟢 -273 B 🟢 -270 B
assets/nightlyBadges-CU1Fd-Zr.js (new) 411 B 🔴 +411 B 🔴 +272 B 🔴 +231 B
assets/Load3dViewerContent-BAV0_X3s.js (new) 137 B 🔴 +137 B 🔴 +103 B 🔴 +101 B
assets/Load3dViewerContent-C2PALkt2.js (removed) 137 B 🟢 -137 B 🟢 -103 B 🟢 -94 B
assets/missingModelMetadata-Bk4zdpgC.js (new) 125 B 🔴 +125 B 🔴 +103 B 🔴 +123 B
assets/missingModelMetadata-C8mJiWD6.js (removed) 125 B 🟢 -125 B 🟢 -103 B 🟢 -109 B
assets/Load3DAdvanced-CNgF_8jD.js (removed) 122 B 🟢 -122 B 🟢 -97 B 🟢 -90 B
assets/Load3DAdvanced-DZYX7Hm9.js (new) 122 B 🔴 +122 B 🔴 +97 B 🔴 +87 B
assets/WidgetLegacy-Dn71cKsf.js (new) 117 B 🔴 +117 B 🔴 +106 B 🔴 +101 B
assets/WidgetLegacy-DvPYO7td.js (removed) 117 B 🟢 -117 B 🟢 -106 B 🟢 -95 B
assets/workflowDraftStoreV2-CXstGWJ6.js (new) 112 B 🔴 +112 B 🔴 +101 B 🔴 +109 B
assets/workflowDraftStoreV2-NF0XXd1X.js (removed) 112 B 🟢 -112 B 🟢 -101 B 🟢 -109 B
assets/Load3D-DsH7-Ed5.js (removed) 98 B 🟢 -98 B 🟢 -89 B 🟢 -94 B
assets/Load3D-OjCfxkLy.js (new) 98 B 🔴 +98 B 🔴 +89 B 🔴 +82 B
assets/changeTracker-BhCaKN8y.js (new) 91 B 🔴 +91 B 🔴 +93 B 🔴 +85 B
assets/changeTracker-CjIw-Msg.js (removed) 91 B 🟢 -91 B 🟢 -93 B 🟢 -80 B

Status: 68 added / 68 removed / 218 unchanged

⚡ Performance Report

canvas-idle: · 60.0 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 0ms TBT · 66.5 MB heap
canvas-mouse-sweep: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 54.1 MB heap
canvas-zoom-sweep: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 69.3 MB heap
dom-widget-clipping: · 60.0 avg FPS · 59.9 P5 FPS ✅ (target: ≥52) · 0ms TBT · 50.8 MB heap
large-graph-idle: · 60.0 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 0ms TBT · 69.7 MB heap
large-graph-pan: · 60.0 avg FPS · 59.9 P5 FPS ✅ (target: ≥52) · 0ms TBT · 60.0 MB heap
large-graph-zoom: · 60.0 avg FPS · 59.9 P5 FPS ✅ (target: ≥52) · 0ms TBT · 65.5 MB heap
minimap-idle: · 60.0 avg FPS · 59.9 P5 FPS ✅ (target: ≥52) · 0ms TBT · 70.1 MB heap
subgraph-dom-widget-clipping: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 51.7 MB heap
subgraph-idle: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 66.7 MB heap
subgraph-mouse-sweep: · 60.0 avg FPS · 59.9 P5 FPS ✅ (target: ≥52) · 0ms TBT · 57.9 MB heap
subgraph-transition-enter: · 60.0 avg FPS · 59.9 P5 FPS ✅ (target: ≥52) · 134ms TBT · 73.3 MB heap
viewport-pan-sweep: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 71.5 MB heap
vue-large-graph-idle: · 56.3 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 0ms TBT · 159.8 MB heap
vue-large-graph-pan: · 57.1 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 33ms TBT · 174.8 MB heap
workflow-execution: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 65.5 MB heap

⚠️ 6 regressions detected

Show regressions
Metric Baseline PR (median) Δ Sig
canvas-idle: task duration 448ms 468ms +5% ⚠️ z=2.4
canvas-mouse-sweep: layout duration 3ms 5ms +62% ⚠️ z=9.4
canvas-mouse-sweep: task duration 861ms 1023ms +19% ⚠️ z=2.7
canvas-zoom-sweep: task duration 357ms 389ms +9% ⚠️ z=2.7
large-graph-pan: task duration 1162ms 1210ms +4% ⚠️ z=3.0
subgraph-idle: task duration 445ms 459ms +3% ⚠️ z=2.8
All metrics
Metric Baseline PR (median) Δ Sig
canvas-idle: avg frame time 17ms 17ms +0% z=-0.1
canvas-idle: p95 frame time 17ms 17ms +1%
canvas-idle: layout duration 0ms 0ms +0%
canvas-idle: style recalc duration 7ms 8ms +16% z=-2.8
canvas-idle: layout count 0 0 +0%
canvas-idle: style recalc count 9 10 +11% z=-2.1
canvas-idle: task duration 448ms 468ms +5% ⚠️ z=2.4
canvas-idle: script duration 6ms 8ms +16% z=-8.0
canvas-idle: TBT 0ms 0ms +0%
canvas-idle: heap used 66.3 MB 66.5 MB +0%
canvas-idle: DOM nodes 18 20 +11% z=-2.0
canvas-idle: event listeners 4 4 +0% z=-1.6
canvas-mouse-sweep: avg frame time 17ms 17ms +0% z=0.2
canvas-mouse-sweep: p95 frame time 17ms 17ms +0%
canvas-mouse-sweep: layout duration 3ms 5ms +62% ⚠️ z=9.4
canvas-mouse-sweep: style recalc duration 35ms 46ms +30% z=1.1
canvas-mouse-sweep: layout count 12 12 +0%
canvas-mouse-sweep: style recalc count 74 78 +5% z=-0.3
canvas-mouse-sweep: task duration 861ms 1023ms +19% ⚠️ z=2.7
canvas-mouse-sweep: script duration 110ms 117ms +6% z=-2.9
canvas-mouse-sweep: TBT 0ms 0ms +0%
canvas-mouse-sweep: heap used 47.5 MB 54.1 MB +14%
canvas-mouse-sweep: DOM nodes -278 -115 -59% z=-68.4
canvas-mouse-sweep: event listeners -153 -75 -51% z=-19.9
canvas-zoom-sweep: avg frame time 17ms 17ms +0% z=0.5
canvas-zoom-sweep: p95 frame time 17ms 17ms -0%
canvas-zoom-sweep: layout duration 1ms 1ms +15% z=0.8
canvas-zoom-sweep: style recalc duration 16ms 18ms +11% z=-0.7
canvas-zoom-sweep: layout count 6 6 +0%
canvas-zoom-sweep: style recalc count 32 31 -5% z=-1.7
canvas-zoom-sweep: task duration 357ms 389ms +9% ⚠️ z=2.7
canvas-zoom-sweep: script duration 9ms 10ms +17% z=-5.7
canvas-zoom-sweep: TBT 0ms 0ms +0%
canvas-zoom-sweep: heap used 69.0 MB 69.3 MB +0%
canvas-zoom-sweep: DOM nodes 77 78 +1% z=-2.2
canvas-zoom-sweep: event listeners 19 19 +0% z=-0.9
dom-widget-clipping: avg frame time 17ms 17ms -0% z=-0.2
dom-widget-clipping: p95 frame time 17ms 17ms +0%
dom-widget-clipping: layout duration 0ms 0ms +0%
dom-widget-clipping: style recalc duration 7ms 7ms +9% z=-3.0
dom-widget-clipping: layout count 0 0 +0%
dom-widget-clipping: style recalc count 11 11 +0% z=-4.2
dom-widget-clipping: task duration 360ms 369ms +3% z=0.3
dom-widget-clipping: script duration 54ms 52ms -3% z=-4.9
dom-widget-clipping: TBT 0ms 0ms +0%
dom-widget-clipping: heap used 50.8 MB 50.8 MB -0%
dom-widget-clipping: DOM nodes 18 18 +0% z=-2.9
dom-widget-clipping: event listeners 0 0 +0% variance too high
large-graph-idle: avg frame time 17ms 17ms +0% z=-0.2
large-graph-idle: p95 frame time 17ms 17ms +1%
large-graph-idle: layout duration 0ms 0ms +0%
large-graph-idle: style recalc duration 8ms 7ms -5% z=-4.6
large-graph-idle: layout count 0 0 +0%
large-graph-idle: style recalc count 8 9 +6% z=-9.9
large-graph-idle: task duration 594ms 609ms +3% z=1.2
large-graph-idle: script duration 13ms 14ms +6% z=-8.5
large-graph-idle: TBT 0ms 0ms +0%
large-graph-idle: heap used 69.4 MB 69.7 MB +0%
large-graph-idle: DOM nodes -284 -283 -1% z=-339.9
large-graph-idle: event listeners -149 -149 +0% z=-28.7
large-graph-pan: avg frame time 17ms 17ms +0% z=-0.2
large-graph-pan: p95 frame time 17ms 17ms -0%
large-graph-pan: layout duration 0ms 0ms +0%
large-graph-pan: style recalc duration 13ms 13ms +5% z=-4.8
large-graph-pan: layout count 0 0 +0%
large-graph-pan: style recalc count 68 68 +0% z=-2.4
large-graph-pan: task duration 1162ms 1210ms +4% ⚠️ z=3.0
large-graph-pan: script duration 327ms 317ms -3% z=-4.6
large-graph-pan: TBT 0ms 0ms +0%
large-graph-pan: heap used 68.2 MB 60.0 MB -12%
large-graph-pan: DOM nodes -285 -285 +0% z=-184.3
large-graph-pan: event listeners -149 -163 +9% z=-202.8
large-graph-zoom: avg frame time 17ms 17ms +0%
large-graph-zoom: p95 frame time 17ms 17ms +0%
large-graph-zoom: layout duration 7ms 8ms +10%
large-graph-zoom: style recalc duration 14ms 15ms +5%
large-graph-zoom: layout count 60 60 +0%
large-graph-zoom: style recalc count 65 66 +1%
large-graph-zoom: task duration 1316ms 1348ms +2%
large-graph-zoom: script duration 368ms 359ms -2%
large-graph-zoom: TBT 0ms 0ms +0%
large-graph-zoom: heap used 75.4 MB 65.5 MB -13%
large-graph-zoom: DOM nodes 12 -137 -1238%
large-graph-zoom: event listeners 8 -73 -1006%
minimap-idle: avg frame time 17ms 17ms +0% z=-0.4
minimap-idle: p95 frame time 17ms 17ms -0%
minimap-idle: layout duration 0ms 0ms +0%
minimap-idle: style recalc duration 7ms 7ms +0% z=-3.5
minimap-idle: layout count 0 0 +0%
minimap-idle: style recalc count 8 8 -6% z=-3.0
minimap-idle: task duration 578ms 611ms +6% z=1.8
minimap-idle: script duration 14ms 14ms +4% z=-8.5
minimap-idle: TBT 0ms 0ms +0%
minimap-idle: heap used 69.3 MB 70.1 MB +1%
minimap-idle: DOM nodes -283 -283 +0% z=-220.9
minimap-idle: event listeners -179 -149 -17% z=-232.6
subgraph-dom-widget-clipping: avg frame time 17ms 17ms +0% z=0.1
subgraph-dom-widget-clipping: p95 frame time 17ms 17ms -0%
subgraph-dom-widget-clipping: layout duration 0ms 0ms +0%
subgraph-dom-widget-clipping: style recalc duration 10ms 11ms +10% z=-2.0
subgraph-dom-widget-clipping: layout count 0 0 +0%
subgraph-dom-widget-clipping: style recalc count 47 48 +1% z=-0.8
subgraph-dom-widget-clipping: task duration 397ms 407ms +3% z=1.6
subgraph-dom-widget-clipping: script duration 118ms 118ms -0% z=-1.7
subgraph-dom-widget-clipping: TBT 0ms 0ms +0%
subgraph-dom-widget-clipping: heap used 51.7 MB 51.7 MB +0%
subgraph-dom-widget-clipping: DOM nodes 20 21 +5% z=-1.1
subgraph-dom-widget-clipping: event listeners 6 7 +17% z=-1.6
subgraph-idle: avg frame time 17ms 17ms +0% z=0.9
subgraph-idle: p95 frame time 17ms 17ms -0%
subgraph-idle: layout duration 0ms 0ms +0%
subgraph-idle: style recalc duration 8ms 8ms -2% z=-3.1
subgraph-idle: layout count 0 0 +0%
subgraph-idle: style recalc count 9 10 +6% z=-2.1
subgraph-idle: task duration 445ms 459ms +3% ⚠️ z=2.8
subgraph-idle: script duration 6ms 7ms +24% z=-4.9
subgraph-idle: TBT 0ms 0ms +0%
subgraph-idle: heap used 66.5 MB 66.7 MB +0%
subgraph-idle: DOM nodes 18 19 +6% z=-1.9
subgraph-idle: event listeners 4 4 +0% variance too high
subgraph-mouse-sweep: avg frame time 17ms 17ms +0% z=0.4
subgraph-mouse-sweep: p95 frame time 17ms 17ms -0%
subgraph-mouse-sweep: layout duration 4ms 5ms +12% z=0.2
subgraph-mouse-sweep: style recalc duration 35ms 38ms +9% z=-1.3
subgraph-mouse-sweep: layout count 16 16 +0%
subgraph-mouse-sweep: style recalc count 76 76 +0% z=-2.1
subgraph-mouse-sweep: task duration 734ms 814ms +11% z=0.7
subgraph-mouse-sweep: script duration 83ms 82ms -2% z=-2.9
subgraph-mouse-sweep: TBT 0ms 0ms +0%
subgraph-mouse-sweep: heap used 57.9 MB 57.9 MB -0%
subgraph-mouse-sweep: DOM nodes 61 62 +2% z=-2.2
subgraph-mouse-sweep: event listeners 4 4 +0% variance too high
subgraph-transition-enter: avg frame time 17ms 17ms -0%
subgraph-transition-enter: p95 frame time 17ms 17ms -1%
subgraph-transition-enter: layout duration 13ms 11ms -10%
subgraph-transition-enter: style recalc duration 30ms 28ms -8%
subgraph-transition-enter: layout count 15 14 -7%
subgraph-transition-enter: style recalc count 20 19 -5%
subgraph-transition-enter: task duration 899ms 927ms +3%
subgraph-transition-enter: script duration 15ms 17ms +13%
subgraph-transition-enter: TBT 135ms 134ms -1%
subgraph-transition-enter: heap used 76.4 MB 73.3 MB -4%
subgraph-transition-enter: DOM nodes 13673 13673 +0%
subgraph-transition-enter: event listeners 2375 2375 +0%
viewport-pan-sweep: avg frame time 17ms 17ms +0%
viewport-pan-sweep: p95 frame time 17ms 17ms -0%
viewport-pan-sweep: layout duration 0ms 0ms +0%
viewport-pan-sweep: style recalc duration 35ms 38ms +8%
viewport-pan-sweep: layout count 0 0 +0%
viewport-pan-sweep: style recalc count 249 250 +0%
viewport-pan-sweep: task duration 4028ms 4241ms +5%
viewport-pan-sweep: script duration 1003ms 980ms -2%
viewport-pan-sweep: TBT 0ms 0ms +0%
viewport-pan-sweep: heap used 70.8 MB 71.5 MB +1%
viewport-pan-sweep: DOM nodes -282 -283 +0%
viewport-pan-sweep: event listeners -135 -133 -1%
vue-large-graph-idle: avg frame time 18ms 18ms +0%
vue-large-graph-idle: p95 frame time 17ms 17ms +0%
vue-large-graph-idle: layout duration 0ms 0ms +0%
vue-large-graph-idle: style recalc duration 0ms 0ms +0%
vue-large-graph-idle: layout count 0 0 +0%
vue-large-graph-idle: style recalc count 0 0 +0%
vue-large-graph-idle: task duration 16117ms 14903ms -8%
vue-large-graph-idle: script duration 106ms 99ms -6%
vue-large-graph-idle: TBT 0ms 0ms +0%
vue-large-graph-idle: heap used 159.3 MB 159.8 MB +0%
vue-large-graph-idle: DOM nodes -8312 -8312 +0%
vue-large-graph-idle: event listeners -16387 -16390 +0%
vue-large-graph-pan: avg frame time 18ms 18ms -2%
vue-large-graph-pan: p95 frame time 17ms 17ms +0%
vue-large-graph-pan: layout duration 0ms 0ms +0%
vue-large-graph-pan: style recalc duration 17ms 18ms +11%
vue-large-graph-pan: layout count 0 0 +0%
vue-large-graph-pan: style recalc count 173 161 -7%
vue-large-graph-pan: task duration 19662ms 18129ms -8%
vue-large-graph-pan: script duration 386ms 388ms +0%
vue-large-graph-pan: TBT 41ms 33ms -21%
vue-large-graph-pan: heap used 175.5 MB 174.8 MB -0%
vue-large-graph-pan: DOM nodes -8312 -8314 +0%
vue-large-graph-pan: event listeners -16383 -16385 +0%
workflow-execution: avg frame time 17ms 17ms -0% z=0.6
workflow-execution: p95 frame time 17ms 17ms +0%
workflow-execution: layout duration 0ms 1ms +165% z=-3.4
workflow-execution: style recalc duration 18ms 20ms +10% z=-1.8
workflow-execution: layout count 2 4 +75% z=-2.7
workflow-execution: style recalc count 13 15 +12% z=-1.6
workflow-execution: task duration 108ms 115ms +6% z=-0.7
workflow-execution: script duration 7ms 7ms +5% z=-7.4
workflow-execution: TBT 0ms 0ms +0%
workflow-execution: heap used 65.4 MB 65.5 MB +0%
workflow-execution: DOM nodes 125 125 -0% z=-5.1
workflow-execution: event listeners 97 97 +0% z=10.3
Historical variance (last 15 runs)
Metric μ σ CV
canvas-idle: avg frame time 17ms 0ms 0.0%
canvas-idle: layout duration 0ms 0ms 0.0%
canvas-idle: style recalc duration 11ms 1ms 8.2%
canvas-idle: layout count 0 0 0.0%
canvas-idle: style recalc count 11 1 5.0%
canvas-idle: task duration 395ms 31ms 7.9%
canvas-idle: script duration 25ms 2ms 8.8%
canvas-idle: TBT 0ms 0ms 0.0%
canvas-idle: DOM nodes 23 1 5.6%
canvas-idle: event listeners 12 5 40.9%
canvas-mouse-sweep: avg frame time 17ms 0ms 0.0%
canvas-mouse-sweep: layout duration 4ms 0ms 5.4%
canvas-mouse-sweep: style recalc duration 43ms 3ms 7.4%
canvas-mouse-sweep: layout count 12 0 0.0%
canvas-mouse-sweep: style recalc count 79 2 3.0%
canvas-mouse-sweep: task duration 865ms 58ms 6.7%
canvas-mouse-sweep: script duration 136ms 6ms 4.8%
canvas-mouse-sweep: TBT 0ms 0ms 0.0%
canvas-mouse-sweep: DOM nodes 62 3 4.2%
canvas-mouse-sweep: event listeners 8 4 49.4%
canvas-zoom-sweep: avg frame time 17ms 0ms 0.0%
canvas-zoom-sweep: layout duration 1ms 0ms 7.0%
canvas-zoom-sweep: style recalc duration 19ms 2ms 8.0%
canvas-zoom-sweep: layout count 6 0 0.0%
canvas-zoom-sweep: style recalc count 31 0 1.5%
canvas-zoom-sweep: task duration 327ms 23ms 7.1%
canvas-zoom-sweep: script duration 27ms 3ms 11.1%
canvas-zoom-sweep: TBT 0ms 0ms 0.0%
canvas-zoom-sweep: DOM nodes 79 1 1.0%
canvas-zoom-sweep: event listeners 24 5 21.8%
dom-widget-clipping: avg frame time 17ms 0ms 0.0%
dom-widget-clipping: layout duration 0ms 0ms 0.0%
dom-widget-clipping: style recalc duration 10ms 1ms 8.0%
dom-widget-clipping: layout count 0 0 0.0%
dom-widget-clipping: style recalc count 13 0 3.8%
dom-widget-clipping: task duration 365ms 16ms 4.5%
dom-widget-clipping: script duration 68ms 3ms 4.8%
dom-widget-clipping: TBT 0ms 0ms 0.0%
dom-widget-clipping: DOM nodes 22 1 6.4%
dom-widget-clipping: event listeners 8 6 81.2%
large-graph-idle: avg frame time 17ms 0ms 0.0%
large-graph-idle: layout duration 0ms 0ms 0.0%
large-graph-idle: style recalc duration 12ms 1ms 8.6%
large-graph-idle: layout count 0 0 0.0%
large-graph-idle: style recalc count 12 0 2.7%
large-graph-idle: task duration 542ms 54ms 10.0%
large-graph-idle: script duration 102ms 11ms 10.3%
large-graph-idle: TBT 0ms 0ms 0.0%
large-graph-idle: DOM nodes 25 1 3.7%
large-graph-idle: event listeners 26 6 23.2%
large-graph-pan: avg frame time 17ms 0ms 0.0%
large-graph-pan: layout duration 0ms 0ms 0.0%
large-graph-pan: style recalc duration 17ms 1ms 4.6%
large-graph-pan: layout count 0 0 0.0%
large-graph-pan: style recalc count 70 1 0.9%
large-graph-pan: task duration 1082ms 43ms 4.0%
large-graph-pan: script duration 408ms 20ms 4.8%
large-graph-pan: TBT 0ms 0ms 0.0%
large-graph-pan: DOM nodes 19 2 8.7%
large-graph-pan: event listeners 5 1 16.8%
minimap-idle: avg frame time 17ms 0ms 0.0%
minimap-idle: layout duration 0ms 0ms 0.0%
minimap-idle: style recalc duration 10ms 1ms 8.6%
minimap-idle: layout count 0 0 0.0%
minimap-idle: style recalc count 10 1 7.1%
minimap-idle: task duration 527ms 47ms 9.0%
minimap-idle: script duration 98ms 10ms 10.1%
minimap-idle: TBT 0ms 0ms 0.0%
minimap-idle: DOM nodes 19 1 7.1%
minimap-idle: event listeners 5 1 14.4%
subgraph-dom-widget-clipping: avg frame time 17ms 0ms 0.0%
subgraph-dom-widget-clipping: layout duration 0ms 0ms 0.0%
subgraph-dom-widget-clipping: style recalc duration 13ms 1ms 7.4%
subgraph-dom-widget-clipping: layout count 0 0 0.0%
subgraph-dom-widget-clipping: style recalc count 48 1 1.2%
subgraph-dom-widget-clipping: task duration 378ms 18ms 4.9%
subgraph-dom-widget-clipping: script duration 128ms 6ms 4.9%
subgraph-dom-widget-clipping: TBT 0ms 0ms 0.0%
subgraph-dom-widget-clipping: DOM nodes 22 1 5.0%
subgraph-dom-widget-clipping: event listeners 16 6 36.0%
subgraph-idle: avg frame time 17ms 0ms 0.0%
subgraph-idle: layout duration 0ms 0ms 0.0%
subgraph-idle: style recalc duration 10ms 1ms 7.5%
subgraph-idle: layout count 0 0 0.0%
subgraph-idle: style recalc count 11 1 6.0%
subgraph-idle: task duration 370ms 31ms 8.5%
subgraph-idle: script duration 20ms 3ms 13.2%
subgraph-idle: TBT 0ms 0ms 0.0%
subgraph-idle: DOM nodes 22 1 6.9%
subgraph-idle: event listeners 10 7 64.5%
subgraph-mouse-sweep: avg frame time 17ms 0ms 0.0%
subgraph-mouse-sweep: layout duration 5ms 0ms 6.8%
subgraph-mouse-sweep: style recalc duration 42ms 3ms 7.8%
subgraph-mouse-sweep: layout count 16 0 0.0%
subgraph-mouse-sweep: style recalc count 80 2 2.4%
subgraph-mouse-sweep: task duration 766ms 69ms 9.0%
subgraph-mouse-sweep: script duration 101ms 7ms 6.5%
subgraph-mouse-sweep: TBT 0ms 0ms 0.0%
subgraph-mouse-sweep: DOM nodes 67 2 3.3%
subgraph-mouse-sweep: event listeners 8 4 52.6%
workflow-execution: avg frame time 17ms 0ms 0.0%
workflow-execution: layout duration 2ms 0ms 9.4%
workflow-execution: style recalc duration 24ms 2ms 9.1%
workflow-execution: layout count 5 1 11.0%
workflow-execution: style recalc count 18 2 11.5%
workflow-execution: task duration 123ms 11ms 8.8%
workflow-execution: script duration 29ms 3ms 10.2%
workflow-execution: TBT 0ms 0ms 0.0%
workflow-execution: DOM nodes 161 7 4.4%
workflow-execution: event listeners 52 4 8.4%
Trend (last 15 commits on main)
Metric Trend Dir Latest
canvas-idle: avg frame time ▆▃▆▁▆▃▆█▆▆▄▃▃▄▃ ➡️ 17ms
canvas-idle: p95 frame time ➡️ NaNms
canvas-idle: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
canvas-idle: style recalc duration ▇▇▆▆▃█▄▃▄▃▇▄▁▆▇ ➡️ 11ms
canvas-idle: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
canvas-idle: style recalc count █▃▅▂▅▆▃▁▂▁▂▅▆▅▆ ➡️ 12
canvas-idle: task duration ▃▃▃▆▂▃▃▅▆▂█▃▁▃▃ ➡️ 391ms
canvas-idle: script duration ▄▃▅▇▂▅▃▆▇▅█▄▁▅▆ ➡️ 27ms
canvas-idle: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
canvas-idle: heap used ➡️ NaN MB
canvas-idle: DOM nodes █▇▆▅▃▇▃▁▂▂▅▆▆▆▇ ➡️ 24
canvas-idle: event listeners ▅█▅▄▁▅▁▁▁▄▅▅▁▅▄ 📉 11
canvas-mouse-sweep: avg frame time ▆█▆▃▁▃▁▆▆▁▃▆▆▃▃ ➡️ 17ms
canvas-mouse-sweep: p95 frame time ➡️ NaNms
canvas-mouse-sweep: layout duration ▁▃▂▄▁▂▁▃▆▂█▇▆▄▃ ➡️ 4ms
canvas-mouse-sweep: style recalc duration ▄▄▂▄▁▂▃▃▅▄█▆▂▄▄ ➡️ 43ms
canvas-mouse-sweep: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 12
canvas-mouse-sweep: style recalc count █▅▄▃▂▂▁▄▄▅▆▅▂▇▄ ➡️ 79
canvas-mouse-sweep: task duration █▆▄▂▂▃▂▄▄▅█▆▁▆▄ ➡️ 868ms
canvas-mouse-sweep: script duration ▄▅▄▆▄▆▆▆▅▅█▆▁▅▆ ➡️ 139ms
canvas-mouse-sweep: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
canvas-mouse-sweep: heap used ➡️ NaN MB
canvas-mouse-sweep: DOM nodes █▅▃▃▁▂▂▃▂▄▆▅▃▅▅ ➡️ 64
canvas-mouse-sweep: event listeners █▁▁▁▁▁▇▁▁▁██▇▁█ 📈 13
canvas-zoom-sweep: avg frame time ▅▅█▄▅▁▁▁▅▁▁▅▄▅▁ ➡️ 17ms
canvas-zoom-sweep: p95 frame time ➡️ NaNms
canvas-zoom-sweep: layout duration ▆▅▅▄▁▁█▅▃▅▇▆▁▂▆ ➡️ 1ms
canvas-zoom-sweep: style recalc duration ▆▅▄▆▅▃█▆▇▅▇▄▁▃▅ ➡️ 20ms
canvas-zoom-sweep: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 6
canvas-zoom-sweep: style recalc count ▁▁▃▄▆▃▆█▄▄▆▁▆▁▆ ➡️ 32
canvas-zoom-sweep: task duration ▄▂▁▇▂▂▄▅▆▃█▄▁▁▅ ➡️ 338ms
canvas-zoom-sweep: script duration ▃▃▂▇▂▂▅▇▆▅█▄▁▂▆ ➡️ 30ms
canvas-zoom-sweep: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
canvas-zoom-sweep: heap used ➡️ NaN MB
canvas-zoom-sweep: DOM nodes ▄▃▁▅█▁▃▆▄▅▅▃▃▄▃ ➡️ 79
canvas-zoom-sweep: event listeners ▁▁▂▅█▂▁▅▁▅▅▄▁▅▁ ➡️ 19
dom-widget-clipping: avg frame time ▂▄▅▅▂▄█▇▅▇▇▅▅▁▇ ➡️ 17ms
dom-widget-clipping: p95 frame time ➡️ NaNms
dom-widget-clipping: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
dom-widget-clipping: style recalc duration ▆▆▂▆▄▃██▄▁▆▇▆▃▅ ➡️ 10ms
dom-widget-clipping: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
dom-widget-clipping: style recalc count ▇█▅█▅▄█▇▇▁▇▄▇▂▅ ➡️ 13
dom-widget-clipping: task duration ▃▃▁▅▄▃▅▆▅▂▇█▁▅▅ ➡️ 371ms
dom-widget-clipping: script duration ▅▄▄▆▆▅▇▇▆▃█▇▁▇▇ ➡️ 71ms
dom-widget-clipping: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
dom-widget-clipping: heap used ➡️ NaN MB
dom-widget-clipping: DOM nodes ▇▇▄▇▅▄█▇▅▁▅▄▇▃▄ ➡️ 21
dom-widget-clipping: event listeners ▅▅▅▅▁▅██▁▁▁▁█▁▁ 📉 2
large-graph-idle: avg frame time ▅▅▅▅▅▂▁▂▄▅▄▂▂▅█ ➡️ 17ms
large-graph-idle: p95 frame time ➡️ NaNms
large-graph-idle: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
large-graph-idle: style recalc duration ▅▅▅▆▄▅▃▄▅▅▆█▁▄▆ ➡️ 13ms
large-graph-idle: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
large-graph-idle: style recalc count █▆█▃▃▁▃▆▃▆▆▃▆██ ➡️ 12
large-graph-idle: task duration ▂▃▂▆▂▃▃▇▅▃██▁▂▅ ➡️ 569ms
large-graph-idle: script duration ▄▅▄▆▄▅▅▇▆▅█▆▁▃▆ ➡️ 110ms
large-graph-idle: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
large-graph-idle: heap used ➡️ NaN MB
large-graph-idle: DOM nodes ▆█▅▂▅▃▁▂▃▅▅▆▂▆▅ ➡️ 25
large-graph-idle: event listeners ███▇██▄▁▄▇▇█▂█▇ ➡️ 29
large-graph-pan: avg frame time ▆▃▃▆█▃▁█▆▆▆▆█▁▆ ➡️ 17ms
large-graph-pan: p95 frame time ➡️ NaNms
large-graph-pan: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
large-graph-pan: style recalc duration ▃▂▄▄▁▅▂▂▁▄▄█▃▁▂ ➡️ 17ms
large-graph-pan: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
large-graph-pan: style recalc count ▆▃█▂▃▂▂▂▁▇▅▃█▆▃ ➡️ 69
large-graph-pan: task duration ▄▃▄▆▄▄▄▆▄▄█▆▁▂▅ ➡️ 1100ms
large-graph-pan: script duration ▅▄▅▆▆▅▄▆▄▅█▄▁▄▅ ➡️ 413ms
large-graph-pan: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
large-graph-pan: heap used ➡️ NaN MB
large-graph-pan: DOM nodes ▅▃▆▂▄▁▃▁▁▅▁▂█▅▂ ➡️ 18
large-graph-pan: event listeners █▆█▁▁▆▁▁▃▆▁▃██▃ ➡️ 5
minimap-idle: avg frame time ▃▆▆▃█▁█▆▆▃▃▆█▆█ ➡️ 17ms
minimap-idle: p95 frame time ➡️ NaNms
minimap-idle: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
minimap-idle: style recalc duration ▄█▁█▅▅█▅▅▃▅▁▁▄▆ ➡️ 10ms
minimap-idle: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
minimap-idle: style recalc count ▃▅▂▄█▃▆▁▂▅▂▁▅▆▃ ➡️ 9
minimap-idle: task duration ▃▄▁▅▁▃▄▅▇▃█▅▁▁▅ ➡️ 547ms
minimap-idle: script duration ▄▆▃▇▃▅▆▆▇▅█▅▁▃▆ ➡️ 106ms
minimap-idle: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
minimap-idle: heap used ➡️ NaN MB
minimap-idle: DOM nodes ▃▅▂▄█▃▆▁▂▅▂▁▅▆▃ ➡️ 19
minimap-idle: event listeners ▃▃▆▁▁▁▃▁▁▆▁▃█▆▁ ➡️ 4
subgraph-dom-widget-clipping: avg frame time ▅▄▄▄▄▄█▄▄▄▃▁▆▃▃ ➡️ 17ms
subgraph-dom-widget-clipping: p95 frame time ➡️ NaNms
subgraph-dom-widget-clipping: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
subgraph-dom-widget-clipping: style recalc duration ▂▄▃▅▅▃▂▅▇▃▄█▁▄▆ ➡️ 14ms
subgraph-dom-widget-clipping: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
subgraph-dom-widget-clipping: style recalc count ▇█▆▃▆▃▁▆█▇▃▆▇█▅ ➡️ 48
subgraph-dom-widget-clipping: task duration ▂▃▃▆▅▅▂▅█▂▆█▁▂▇ ➡️ 398ms
subgraph-dom-widget-clipping: script duration ▃▃▃▄▅▅▂▄█▂▅▇▁▂▅ ➡️ 131ms
subgraph-dom-widget-clipping: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
subgraph-dom-widget-clipping: heap used ➡️ NaN MB
subgraph-dom-widget-clipping: DOM nodes ▅▇▅▂▅▂▁▅▅▅▁▇▅█▄ ➡️ 22
subgraph-dom-widget-clipping: event listeners ▅▅▅▂▅▁▅██▁▁█▅█▅ 📈 16
subgraph-idle: avg frame time ▆▆█▁▆▃▆▆▆▃▆▁▃▆█ ➡️ 17ms
subgraph-idle: p95 frame time ➡️ NaNms
subgraph-idle: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
subgraph-idle: style recalc duration ▁▇▃▆▂▄▂▃▃▆▆▄▃▇█ ➡️ 12ms
subgraph-idle: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
subgraph-idle: style recalc count ▃▆▃▃▂▅▁▂▁▆▃▃██▇ ➡️ 12
subgraph-idle: task duration ▁▃▁▇▁▁▃▆▅▂█▅▁▁▄ ➡️ 378ms
subgraph-idle: script duration ▁▃▂▇▁▂▃▇▆▂█▅▂▁▅ ➡️ 22ms
subgraph-idle: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
subgraph-idle: heap used ➡️ NaN MB
subgraph-idle: DOM nodes ▃▅▃▂▁▄▁▂▁▅▃▂▇█▇ ➡️ 24
subgraph-idle: event listeners ▁▅▁▁▁▁▁▁▁▅▄▁███ 📈 21
subgraph-mouse-sweep: avg frame time ▅▄▁▃▃▄▆▄▆▃▃█▁▃▃ ➡️ 17ms
subgraph-mouse-sweep: p95 frame time ➡️ NaNms
subgraph-mouse-sweep: layout duration ▁▄▄▄▃▃▅▅▅▂█▇▂▃▆ ➡️ 5ms
subgraph-mouse-sweep: style recalc duration ▃▂▄▅▂▃▄▅█▃█▆▁▂▅ ➡️ 43ms
subgraph-mouse-sweep: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 16
subgraph-mouse-sweep: style recalc count ▅▂▅▅▁▄▃▅█▅▆▄▂▄▅ ➡️ 81
subgraph-mouse-sweep: task duration ▃▂▄▅▂▄▄▅▇▄█▆▁▃▅ ➡️ 785ms
subgraph-mouse-sweep: script duration ▄▅▄▇▅▅▆▇▆▅██▁▄▆ ➡️ 105ms
subgraph-mouse-sweep: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
subgraph-mouse-sweep: heap used ➡️ NaN MB
subgraph-mouse-sweep: DOM nodes ▅▁▄▅▁▄▃▃█▅▅▄▂▅▃ ➡️ 66
subgraph-mouse-sweep: event listeners ▇▁▂▇▁▂▂▂█▇▂▂▇▇▂ 📈 5
workflow-execution: avg frame time ▆▆▆▄▆▆▃▄▁▄█▆▅▄▆ ➡️ 17ms
workflow-execution: p95 frame time ➡️ NaNms
workflow-execution: layout duration ▁▆▁▃▂▄▃▂▃▃▅█▄▂▅ ➡️ 2ms
workflow-execution: style recalc duration ▃▇▅▇▁▅▆▇█▁██▂▄▆ ➡️ 25ms
workflow-execution: layout count ▁█▂▃▂▃▃▁▃▃▄▃▂▃▂ ➡️ 5
workflow-execution: style recalc count ▃█▅▇▁▄▅▆▅▅▅▅▄▄▂ ➡️ 15
workflow-execution: task duration ▂▅▄▅▁▄▆▆▆▁▇█▁▃▃ ➡️ 120ms
workflow-execution: script duration ▄▃▄▄▃▅▄▅▆▂▇█▁▃▄ ➡️ 29ms
workflow-execution: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
workflow-execution: heap used ➡️ NaN MB
workflow-execution: DOM nodes ▂█▃▆▁▄▃▅▃█▃▃▄▃▁ ➡️ 152
workflow-execution: event listeners ▅███▁▅███▁██▅█▅ ➡️ 49
Raw data
{
  "timestamp": "2026-08-14T02:17:36.374Z",
  "gitSha": "787b67db593ec3df63bff09fd124be72638969f4",
  "branch": "glary/fix-canvas-navigation-mode-reset",
  "measurements": [
    {
      "name": "canvas-idle",
      "durationMs": 2008.5180000000094,
      "styleRecalcs": 10,
      "styleRecalcDurationMs": 7.882999999999999,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 469.65099999999995,
      "heapDeltaBytes": 5151708,
      "heapUsedBytes": 69706156,
      "domNodes": 20,
      "jsHeapTotalBytes": 25165824,
      "scriptDurationMs": 7.2940000000000005,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "canvas-idle",
      "durationMs": 2028.1719999999837,
      "styleRecalcs": 10,
      "styleRecalcDurationMs": 8.821999999999997,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 467.00699999999995,
      "heapDeltaBytes": 5136308,
      "heapUsedBytes": 69834344,
      "domNodes": 20,
      "jsHeapTotalBytes": 24903680,
      "scriptDurationMs": 7.771999999999999,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "canvas-mouse-sweep",
      "durationMs": 2219.251000000014,
      "styleRecalcs": 79,
      "styleRecalcDurationMs": 51.772,
      "layouts": 12,
      "layoutDurationMs": 6.976000000000001,
      "taskDurationMs": 1154.3400000000001,
      "heapDeltaBytes": -14662280,
      "heapUsedBytes": 49810124,
      "domNodes": -288,
      "jsHeapTotalBytes": 23040000,
      "scriptDurationMs": 123.616,
      "eventListeners": -153,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.670000000000012,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "canvas-mouse-sweep",
      "durationMs": 1867.2220000000266,
      "styleRecalcs": 77,
      "styleRecalcDurationMs": 40.42900000000001,
      "layouts": 12,
      "layoutDurationMs": 3.9259999999999997,
      "taskDurationMs": 892.244,
      "heapDeltaBytes": -663240,
      "heapUsedBytes": 63738048,
      "domNodes": 59,
      "jsHeapTotalBytes": 25952256,
      "scriptDurationMs": 110.196,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.699999999999818
    },
    {
      "name": "canvas-zoom-sweep",
      "durationMs": 1707.6789999999846,
      "styleRecalcs": 30,
      "styleRecalcDurationMs": 17.291,
      "layouts": 6,
      "layoutDurationMs": 0.565,
      "taskDurationMs": 393.896,
      "heapDeltaBytes": 8103792,
      "heapUsedBytes": 72761208,
      "domNodes": 77,
      "jsHeapTotalBytes": 24641536,
      "scriptDurationMs": 9.772,
      "eventListeners": 19,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "canvas-zoom-sweep",
      "durationMs": 1754.122999999936,
      "styleRecalcs": 31,
      "styleRecalcDurationMs": 18.852,
      "layouts": 6,
      "layoutDurationMs": 0.7910000000000003,
      "taskDurationMs": 384.16900000000004,
      "heapDeltaBytes": 8019784,
      "heapUsedBytes": 72487644,
      "domNodes": 78,
      "jsHeapTotalBytes": 24903680,
      "scriptDurationMs": 10.56,
      "eventListeners": 19,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "dom-widget-clipping",
      "durationMs": 571.0020000000213,
      "styleRecalcs": 11,
      "styleRecalcDurationMs": 6.926999999999999,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 362.772,
      "heapDeltaBytes": -11388988,
      "heapUsedBytes": 53161056,
      "domNodes": 18,
      "jsHeapTotalBytes": 25165824,
      "scriptDurationMs": 51.216,
      "eventListeners": 0,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333332,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "dom-widget-clipping",
      "durationMs": 583.1269999999904,
      "styleRecalcs": 11,
      "styleRecalcDurationMs": 8.009999999999998,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 375.81,
      "heapDeltaBytes": -11137140,
      "heapUsedBytes": 53272956,
      "domNodes": 18,
      "jsHeapTotalBytes": 25165824,
      "scriptDurationMs": 52.532999999999994,
      "eventListeners": 0,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.699999999999818
    },
    {
      "name": "large-graph-idle",
      "durationMs": 2040.5890000000113,
      "styleRecalcs": 9,
      "styleRecalcDurationMs": 7.641000000000002,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 607.157,
      "heapDeltaBytes": 12680724,
      "heapUsedBytes": 72685832,
      "domNodes": -282,
      "jsHeapTotalBytes": 3244032,
      "scriptDurationMs": 14.316999999999997,
      "eventListeners": -149,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "large-graph-idle",
      "durationMs": 2041.532000000018,
      "styleRecalcs": 8,
      "styleRecalcDurationMs": 7.169999999999999,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 611.39,
      "heapDeltaBytes": 13208232,
      "heapUsedBytes": 73499804,
      "domNodes": -283,
      "jsHeapTotalBytes": 3244032,
      "scriptDurationMs": 12.686000000000003,
      "eventListeners": -149,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "large-graph-pan",
      "durationMs": 2163.1419999999935,
      "styleRecalcs": 68,
      "styleRecalcDurationMs": 13.405000000000001,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 1198.185,
      "heapDeltaBytes": 1448104,
      "heapUsedBytes": 63328004,
      "domNodes": -284,
      "jsHeapTotalBytes": 3960832,
      "scriptDurationMs": 319.256,
      "eventListeners": -147,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.699999999999818
    },
    {
      "name": "large-graph-pan",
      "durationMs": 2131.7850000000362,
      "styleRecalcs": 68,
      "styleRecalcDurationMs": 13.566000000000003,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 1221.671,
      "heapDeltaBytes": 1126988,
      "heapUsedBytes": 62426492,
      "domNodes": -286,
      "jsHeapTotalBytes": 3960832,
      "scriptDurationMs": 314.83700000000005,
      "eventListeners": -179,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333335,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "large-graph-zoom",
      "durationMs": 3130.3210000000377,
      "styleRecalcs": 66,
      "styleRecalcDurationMs": 14.516000000000002,
      "layouts": 60,
      "layoutDurationMs": 7.533,
      "taskDurationMs": 1300.286,
      "heapDeltaBytes": 16880156,
      "heapUsedBytes": 78995116,
      "domNodes": 14,
      "jsHeapTotalBytes": 4980736,
      "scriptDurationMs": 353.03,
      "eventListeners": 8,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66999999999998,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "large-graph-zoom",
      "durationMs": 3199.2010000000164,
      "styleRecalcs": 65,
      "styleRecalcDurationMs": 15.254,
      "layouts": 60,
      "layoutDurationMs": 7.8740000000000006,
      "taskDurationMs": 1395.7410000000002,
      "heapDeltaBytes": -3951008,
      "heapUsedBytes": 58342920,
      "domNodes": -287,
      "jsHeapTotalBytes": 3768320,
      "scriptDurationMs": 365.769,
      "eventListeners": -153,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.670000000000012,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "minimap-idle",
      "durationMs": 2031.829000000016,
      "styleRecalcs": 7,
      "styleRecalcDurationMs": 5.711000000000001,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 608.081,
      "heapDeltaBytes": 12723912,
      "heapUsedBytes": 73635780,
      "domNodes": -283,
      "jsHeapTotalBytes": 2981888,
      "scriptDurationMs": 14.411,
      "eventListeners": -149,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333335,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "minimap-idle",
      "durationMs": 2009.439000000043,
      "styleRecalcs": 8,
      "styleRecalcDurationMs": 7.613999999999999,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 614.771,
      "heapDeltaBytes": 12697104,
      "heapUsedBytes": 73472464,
      "domNodes": -283,
      "jsHeapTotalBytes": 3244032,
      "scriptDurationMs": 14.227,
      "eventListeners": -149,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.699999999999818
    },
    {
      "name": "subgraph-dom-widget-clipping",
      "durationMs": 606.9239999999922,
      "styleRecalcs": 47,
      "styleRecalcDurationMs": 10.615000000000002,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 409.6190000000001,
      "heapDeltaBytes": -10546300,
      "heapUsedBytes": 54263392,
      "domNodes": 20,
      "jsHeapTotalBytes": 25427968,
      "scriptDurationMs": 118.24200000000002,
      "eventListeners": 6,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "subgraph-dom-widget-clipping",
      "durationMs": 599.072000000092,
      "styleRecalcs": 48,
      "styleRecalcDurationMs": 11.075999999999999,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 404.29099999999994,
      "heapDeltaBytes": -10295744,
      "heapUsedBytes": 54196952,
      "domNodes": 22,
      "jsHeapTotalBytes": 25952256,
      "scriptDurationMs": 117.163,
      "eventListeners": 8,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666682,
      "p95FrameDurationMs": 16.700000000000273
    },
    {
      "name": "subgraph-idle",
      "durationMs": 2006.4050000000293,
      "styleRecalcs": 9,
      "styleRecalcDurationMs": 7.728000000000001,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 454.133,
      "heapDeltaBytes": 5279736,
      "heapUsedBytes": 69986516,
      "domNodes": 18,
      "jsHeapTotalBytes": 24379392,
      "scriptDurationMs": 6.929000000000001,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66999999999998,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "subgraph-idle",
      "durationMs": 1992.400000000032,
      "styleRecalcs": 10,
      "styleRecalcDurationMs": 8.174000000000001,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 463.01599999999996,
      "heapDeltaBytes": 4953160,
      "heapUsedBytes": 69836880,
      "domNodes": 20,
      "jsHeapTotalBytes": 25165824,
      "scriptDurationMs": 7.631999999999998,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.699999999999818
    },
    {
      "name": "subgraph-mouse-sweep",
      "durationMs": 1710.5770000000007,
      "styleRecalcs": 75,
      "styleRecalcDurationMs": 38.096000000000004,
      "layouts": 16,
      "layoutDurationMs": 4.732,
      "taskDurationMs": 830.4730000000001,
      "heapDeltaBytes": -3822836,
      "heapUsedBytes": 60789048,
      "domNodes": 61,
      "jsHeapTotalBytes": 25165824,
      "scriptDurationMs": 84.029,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "subgraph-mouse-sweep",
      "durationMs": 1727.5070000000028,
      "styleRecalcs": 77,
      "styleRecalcDurationMs": 38.175999999999995,
      "layouts": 16,
      "layoutDurationMs": 4.791,
      "taskDurationMs": 798.3439999999999,
      "heapDeltaBytes": -4008284,
      "heapUsedBytes": 60624396,
      "domNodes": 63,
      "jsHeapTotalBytes": 25690112,
      "scriptDurationMs": 80.11,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.699999999999818
    },
    {
      "name": "subgraph-transition-enter",
      "durationMs": 1399.9520000000416,
      "styleRecalcs": 19,
      "styleRecalcDurationMs": 27.89699999999999,
      "layouts": 14,
      "layoutDurationMs": 11.356000000000002,
      "taskDurationMs": 926.9419999999999,
      "heapDeltaBytes": -9781728,
      "heapUsedBytes": 76901448,
      "domNodes": 13673,
      "jsHeapTotalBytes": 14680064,
      "scriptDurationMs": 17.479000000000003,
      "eventListeners": 2375,
      "totalBlockingTimeMs": 134,
      "frameDurationMs": 16.66333333333335,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "viewport-pan-sweep",
      "durationMs": 8194.165000000055,
      "styleRecalcs": 251,
      "styleRecalcDurationMs": 37.795,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 4149.669,
      "heapDeltaBytes": 15979696,
      "heapUsedBytes": 76340708,
      "domNodes": -282,
      "jsHeapTotalBytes": 3960832,
      "scriptDurationMs": 961.098,
      "eventListeners": -133,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "viewport-pan-sweep",
      "durationMs": 8206.309000000034,
      "styleRecalcs": 249,
      "styleRecalcDurationMs": 37.677,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 4331.975,
      "heapDeltaBytes": 13135824,
      "heapUsedBytes": 73674336,
      "domNodes": -284,
      "jsHeapTotalBytes": 3960832,
      "scriptDurationMs": 998.5530000000001,
      "eventListeners": -133,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "vue-large-graph-idle",
      "durationMs": 16103.492000000017,
      "styleRecalcs": 0,
      "styleRecalcDurationMs": 0,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 14980.948,
      "heapDeltaBytes": -30470340,
      "heapUsedBytes": 167012836,
      "domNodes": -8312,
      "jsHeapTotalBytes": -10424320,
      "scriptDurationMs": 105.21799999999999,
      "eventListeners": -16391,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 17.776666666666642,
      "p95FrameDurationMs": 16.80000000000291
    },
    {
      "name": "vue-large-graph-idle",
      "durationMs": 15888.735999999994,
      "styleRecalcs": 0,
      "styleRecalcDurationMs": 0,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 14824.452000000001,
      "heapDeltaBytes": -43981308,
      "heapUsedBytes": 168097008,
      "domNodes": -8312,
      "jsHeapTotalBytes": -9641984,
      "scriptDurationMs": 92.86500000000001,
      "eventListeners": -16389,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 17.776666666666642,
      "p95FrameDurationMs": 16.80000000000291
    },
    {
      "name": "vue-large-graph-pan",
      "durationMs": 18890.048999999977,
      "styleRecalcs": 164,
      "styleRecalcDurationMs": 18.33700000000002,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 18376.653000000002,
      "heapDeltaBytes": -27692788,
      "heapUsedBytes": 184026896,
      "domNodes": -8312,
      "jsHeapTotalBytes": -11546624,
      "scriptDurationMs": 397.656,
      "eventListeners": -16385,
      "totalBlockingTimeMs": 5,
      "frameDurationMs": 17.780000000000047,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "vue-large-graph-pan",
      "durationMs": 18248.175999999945,
      "styleRecalcs": 158,
      "styleRecalcDurationMs": 18.228000000000023,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 17881.362,
      "heapDeltaBytes": -28091244,
      "heapUsedBytes": 182659632,
      "domNodes": -8316,
      "jsHeapTotalBytes": -9449472,
      "scriptDurationMs": 378.428,
      "eventListeners": -16385,
      "totalBlockingTimeMs": 60,
      "frameDurationMs": 17.223333333333358,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "workflow-execution",
      "durationMs": 475.45800000000327,
      "styleRecalcs": 15,
      "styleRecalcDurationMs": 21.720000000000002,
      "layouts": 4,
      "layoutDurationMs": 1.356,
      "taskDurationMs": 121.13199999999999,
      "heapDeltaBytes": 4998544,
      "heapUsedBytes": 68645780,
      "domNodes": 124,
      "jsHeapTotalBytes": 4980736,
      "scriptDurationMs": 7.735,
      "eventListeners": 97,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "workflow-execution",
      "durationMs": 461.5280000000439,
      "styleRecalcs": 14,
      "styleRecalcDurationMs": 18.623,
      "layouts": 3,
      "layoutDurationMs": 0.76,
      "taskDurationMs": 109.185,
      "heapDeltaBytes": 4930248,
      "heapUsedBytes": 68645620,
      "domNodes": 125,
      "jsHeapTotalBytes": 4980736,
      "scriptDurationMs": 6.791000000000002,
      "eventListeners": 97,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66666666666665,
      "p95FrameDurationMs": 16.800000000000182
    }
  ]
}

@fennuck-bot

fennuck-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

Thanks for the review. No changes needed — nothing actionable was raised, so this is unchanged from the approved state.

For anyone picking this up: the two seat values are inert. Nothing outside the generated types reads max_seats or occupied_seats on BillingStatusResponse — the only .max_seats consumer in src/ is useBillingContext.ts:197, which reads Plan.max_seats, a different object. So this only makes the fixtures satisfy the type; it does not change what any billing test exercises.

Follow-up filed as FE-1507 so this cannot merge red again: typecheck:browser is gated on browser_tests/** changing, but these types break when the types those tests consume change. #13499 touched only packages/ingest-types/**, so the check was skipped on both its PR run and its push to main.

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.24390% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/platform/settings/constants/coreSettings.ts 25.00% 3 Missing ⚠️
src/platform/settings/settingStore.ts 97.22% 1 Missing ⚠️
@@            Coverage Diff             @@
##             main   #14716      +/-   ##
==========================================
- Coverage   82.11%   78.90%   -3.22%     
==========================================
  Files        1882     1883       +1     
  Lines      120655   114379    -6276     
  Branches    36514    33471    -3043     
==========================================
- Hits        99079    90249    -8830     
- Misses      21096    23626    +2530     
- Partials      480      504      +24     
Flag Coverage Δ
unit 72.84% <90.24%> (+0.12%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...rc/platform/settings/constants/canvasNavigation.ts 100.00% <100.00%> (ø)
src/platform/settings/settingStore.ts 94.93% <97.22%> (+7.37%) ⬆️
src/platform/settings/constants/coreSettings.ts 65.26% <25.00%> (-19.06%) ⬇️

... and 521 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Navigation Mode is stored independently of the Left Mouse Click Behavior
and Mouse Wheel Scroll overrides it implies, and the three were kept in
sync by cross-writes in onChange. Two paths lost the mode the user picked:

- applySettingLocally fired onChange before committing the new value, so
  the overrides written by a preset read back the mode it replaced,
  decided they no longer matched it, and demoted it to 'custom'. Commit
  the value first so the cascade observes the mode it is applying.
- A mode stored before the overrides shipped in 1.27.4 is the only value
  on record, so the overrides loaded as their defaults, which describe
  'legacy'. The override handlers then rewrote any other stored mode to
  'custom' on the first load after upgrading. Treat a stored preset as
  authoritative instead: on registration it now supplies the overrides
  that were never stored.

The second point is why fixing only the write order is not enough, and
why the override handlers are not simply muted during registration -
that would keep the dropdown reading Standard while the canvas still
panned and zoomed like Legacy.
@fennuck-bot

fennuck-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

The duplicate-key finding is correct, but deduping is the wrong fix — this PR is now redundant and should be closed.

main picked up #14050 ("Fix Team Workspace seat capacity handling", FE-1497), which added max_seats/occupied_seats to these same fixtures. main was then merged into this branch (6a40902), so every field exists twice: once from #14050 and once from me. That is where the duplicates come from — they were not in my commit.

I verified on current origin/main (b1861ab):

$ pnpm typecheck:browser
Exit 0 — 0 errors

The breakage this PR existed to repair is gone, fixed upstream. Deduping would leave a PR whose only content is changes main already has.

Recommend: close this without merging. I've rebased the dependent fix (#14716) directly onto origin/main — the cherry-pick applied cleanly, and on that base typecheck, typecheck:browser, lint, format:check and the settings unit tests are all clean, with its 3 Playwright regression tests passing.

One correction to my earlier comment on this PR: I said nothing outside the generated types reads max_seats/occupied_seats. That was true when I wrote it, but #14050 changed it — useBillingContext.ts, workspaceApi.ts, useLegacyBilling.ts and useTeamPlan.ts now consume these fields. Another reason to defer entirely to #14050's values rather than mine.

@fennuck-bot
fennuck-bot Bot force-pushed the glary/fix-browser-test-billing-types branch from 6a40902 to b1861ab Compare August 4, 2026 23:17
Base automatically changed from glary/fix-browser-test-billing-types to main August 4, 2026 23:17
@fennuck-bot
fennuck-bot Bot force-pushed the glary/fix-canvas-navigation-mode-reset branch from 3a4bfb6 to 7a82ca3 Compare August 4, 2026 23:17
@fennuck-bot

fennuck-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

Rebased onto main — this PR now contains only the Canvas Navigation fix (4 files), with no billing-fixture churn.

Context for the change of base: this was originally stacked on #14714, which existed to repair typecheck:browser. #14050 ("Fix Team Workspace seat capacity handling", FE-1497) fixed those fixtures upstream first, so #14714 became redundant and was closed; GitHub retargeted this PR to main automatically.

Re-verified on the new base (b1861ab4):

  • cherry-pick applied with no conflicts
  • pnpm typecheck, pnpm typecheck:browser, pnpm lint, pnpm format:check — clean
  • src/platform/settings/ unit tests — 94 passed
  • the 3 Playwright regression tests — all passing; each still fails on main (["custom","standard"], "custom", "panning" respectively)

@coderabbitai review

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 4, 2026
@fennuck-bot

fennuck-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

Thanks — no changes needed, nothing actionable was raised, so the branch is unchanged from the approved state (7a82ca338).

State for the human reviewers:

  • one commit on top of main (b1861ab4), merges clean, no conflicts
  • pnpm typecheck, typecheck:browser, lint, format:check, knip — clean
  • src/platform/settings/ — 94 unit tests pass
  • 3 Playwright regression tests pass here and each still fails on main: ["custom","standard"], "custom", "panning"

Left as a draft pending human review. The one judgement call worth a reviewer's eye is in Comfy.Canvas.NavigationMode.onChange: on the registration replay a stored preset now supplies the overrides that were never stored. The narrower alternative — just ignoring the replay in the two override handlers — keeps the dropdown reading Standard while the canvas still pans and zooms like Legacy, which is why it isn't enough. The third Playwright test is what distinguishes the two.

@DrJKL
DrJKL marked this pull request as ready for review August 4, 2026 23:42
@DrJKL
DrJKL requested a review from a team August 4, 2026 23:42
@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Aug 4, 2026
@dosubot

dosubot Bot commented Aug 4, 2026

Copy link
Copy Markdown

📄 Knowledge review

Dosu skipped reviewing this PR because your organization has used its 200 included credits for the month. Your usage will reset on 2026-09-01. To have Dosu review this PR before then, ask your organization admin to upgrade to a pro account.


Leave Feedback Ask Dosu about ComfyUI_frontend Add Dosu to your team

([id]) => !settingStore.exists(id as keyof Settings)
)
)
if (Object.keys(unset).length) await settingStore.setMany(unset)

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.

This migration belongs in load(), not in an onChange registration replay.

settingStore.ts already has exactly this pattern — migrateZoomThresholdToFontSize(), invoked from load() before any setting is registered:

settingValues.value = await retry(() => api.getSettings(), {...})
await migrateZoomThresholdToFontSize()

and load() even asserts that nothing is registered yet. Its shape is identical to what this branch does: "old key is stored, new key is not → derive the new one and persist it."

Doing it here instead buys three problems that the load() hook does not have:

  1. Un-awaited network write with no error handling. onChange() in settingStore.ts calls setting.onChange(newValue, oldValue) and discards the returned promise. So await settingStore.setMany(unset)api.storeSettingsfetchApi runs detached. If the backend is unreachable, read-only, or 4xx-on-write, this is an unhandled promise rejection fired during app bootstrap, on every load, for exactly the affected profiles. Before this PR the registration replay returned at if (!oldValue) return before touching the network, so addSetting never made a request.

  2. Silent dependence on CORE_SETTINGS array order. This only works because Comfy.Canvas.NavigationMode is registered before LeftMouseClickBehavior and MouseWheelScroll. Reorder the array (alphabetise, move the canvas block, register an override from an extension earlier) and the demotion comes straight back: the override replays first, reads NavigationMode = standard against its own default panning, and writes custom. Nothing in the file states this invariant.

  3. settingsById is not yet populated for the keys being written. setManyapplySettingLocally('Comfy.Canvas.LeftMouseClickBehavior', ...) runs while settingsById.value[key] is still undefined, so onChange(undefined, ...) no-ops and settingChangedEvent returns undefined. It happens to produce the right result, but it means these writes skip dispatchChange (extensions never see them) and skip telemetry, unlike every other settings write. That is load-bearing accidental behaviour.

Hoisting this into a migrateCanvasNavigationOverrides() called from load() fixes all three: it is awaited, it runs before registration so order is irrelevant, and onChange goes back to meaning "the user changed something" (if (!oldValue) return). It also becomes unit-testable without Playwright.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agreed on all three, and fixed in a5b9500 — the repair now lives in migrateCanvasNavigationOverrides(), called from load() right after migrateZoomThresholdToFontSize().

You were right that all three problems were real, and (1) and (3) I had not spotted:

  1. Detached write. Confirmed — onChange() discarded the promise, so the healing POST /settings ran unsupervised on every load for exactly the affected profiles. From load() it is awaited inside the useAsyncState block, so a failure surfaces through settingsError, which GraphCanvas already handles.
  2. Order dependence. This was load-bearing and undocumented, which is the part I am least comfortable having shipped. Running before registration makes it structurally irrelevant rather than relying on CORE_SETTINGS ordering.
  3. settingsById unpopulated. I had noticed the onChange no-op and reasoned "harmless because we want no reconciliation" — I missed that it also silently skipped dispatchChange and telemetry. Accidental correctness, as you say. The migration now writes settingValues directly and calls api.storeSettings once, which is honest about being a migration rather than pretending to be a normal settings write.

NavigationMode.onChange is back to if (!old) return plus the cascade, so it means "the user changed something" again. The preset pairs moved to constants/canvasNavigation.ts so the migration and the handler share one definition without a settingStore ↔ coreSettings cycle.

const oldValue = old as string | undefined
if (!oldValue) return
const preset = CANVAS_NAVIGATION_PRESETS[val as string]
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.

// Registration replay. A preset stored before the overrides shipped in
// 1.27.4 is the only record of the choice, so it has to supply the ones
// still missing instead of being overruled by their defaults.
if (!settingStore.exists('Comfy.Canvas.NavigationMode')) 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.

Keying on exists() rather than the effective value re-arms the bug the moment the default flips.

This guard means "only heal when the mode was explicitly stored." That is safe today only because the effective default of NavigationMode (defaultValue: 'legacy', defaultsByInstallVersion: { '1.25.0': 'legacy' }) happens to agree with the two override defaults (panning / zoom = the legacy pair).

Change either side of that coincidence — ship defaultsByInstallVersion: { '1.28.0': 'standard' }, or flip defaultValue to 'standard' (an obvious near-term product move given the option is literally labelled "Standard (New)") — and a fresh profile has effective mode standard with override defaults describing legacy. This branch returns early because nothing is stored, then LeftMouseClickBehavior's replay sees panning vs standard, mismatches, and writes NavigationMode = "custom". Every new user, first load, no interaction: the exact bug this PR is fixing.

Deriving from the effective value (settingStore.get) instead of the stored one would close this permanently and simplify the branch — a fresh profile would just materialise the overrides implied by its default mode. If you would rather keep the exists() scoping, please at minimum add a comment stating the invariant (override defaults must describe the default NavigationMode) and a unit test that asserts CANVAS_NAVIGATION_PRESETS[defaultNavigationMode] equals the two overrides' defaultValues, so the next person to touch the default trips a test rather than shipping this to every new install.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good catch — this was a latent trap and I have taken the test option.

The exists() guard is gone entirely now that the repair runs from load(), but your underlying point survives the move: the migration reads the stored mode, so a fresh profile with no stored mode still resolves its effective mode from defaultValue / defaultsByInstallVersion while the overrides resolve from theirs. Flip either side and a brand-new install lands on a mode no preset matches, and the override replay demotes it — every new user, first load, exactly as you describe.

Rather than leave that as a comment, constants/canvasNavigation.test.ts now pins it:

it('agrees with the default Navigation Mode', () => {
  const defaultMode = settingById(NAV)?.defaultValue as string
  expect(CANVAS_NAVIGATION_PRESETS[defaultMode]).toEqual(overrideDefaults())
})

it('agrees with every install-versioned Navigation Mode default', () => { ... })

It reads the values out of CORE_SETTINGS rather than hardcoding them, so it asserts the relationship and not the current defaults — shipping defaultsByInstallVersion: { '1.28.0': 'standard' } fails the second test instead of shipping the bug. I covered the versioned map too since that is the likelier vehicle for the change.

I did consider deriving from settingStore.get as you suggested, which would make a fresh profile materialise its default mode's overrides and close it permanently. I went with the invariant test instead because deriving means writing settings for every new install on first load purely to restate defaults, and exists() is used elsewhere to mean "the user set this manually" — I would rather not blur that. Happy to switch if you would prefer the structural fix over the guard rail.

Comment thread src/platform/settings/settingStore.ts Outdated
onChange(settingsById.value[key], newValue, oldValue)
const typedNewValue = newValue as Settings[K]
settingValues.value[key] = typedNewValue
onChange(settingsById.value[key], newValue, oldValue)

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 reorder itself is right, and the new unit test pins it well. Two things worth recording while this line is being touched:

1. The remaining two writes are still concurrent, so the fix is probabilistic, not deterministic.

onChange() (line 40-53) calls setting.onChange(newValue, oldValue) without awaiting it. So when the user picks a preset:

  • set()applySettingLocallyonChangeNavigationMode.onChangesetMany(preset)POST /settings is issued and the promise is dropped on the floor;
  • control returns to set(), which immediately issues POST /settings/Comfy.Canvas.NavigationMode.

Both are in flight against a backend that does a non-atomic read-modify-write of one JSON file with an await request.json() between the read and the write — as the description itself notes. So "leaving one write per key" is accurate about count but the two are racing. If the mode write wins the read, the overrides are lost (harmless — the registration branch re-supplies them next load). If the overrides write wins, the mode is lost, and on the next load LeftMouseClickBehavior replays against the stale mode and demotes it to custom: the original symptom, at lower probability.

Awaiting the handler here would serialise them and make the fix deterministic without depending on the backend follow-up:

await setting.onChange(newValue, oldValue)

Notably, this PR is what makes awaiting safe. Under the old ordering, a handler that wrote back to the same key would recurse forever, because newValue === oldValue compared against a value that had not been committed yet. Committing first makes that guard actually guard. Worth calling out as a second benefit of the change — and worth doing, since the whole PR hinges on the cascade landing intact.

2. Rollback semantics changed. If a synchronous onChange handler throws, the value is now already in settingValues but api.storeSetting is never reached, so local and server diverge until the next load. Previously neither the commit nor the write happened. Low severity — every current handler is either async (rejection is swallowed anyway) or non-throwing — but it is a real change to the contract and the new docblock does not mention it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is the best catch in the review and I have taken it — onChange is now awaited.

You are right that "one write per key" described the count while leaving them racing, and that the losing interleaving reproduces the original symptom. Implemented as:

const handled = setting?.onChange?.(newValue, oldValue)
if (setting) app.ui.settings.dispatchChange(setting.id, newValue, oldValue)
await handled

Started before dispatchChange and awaited after it, so extension listeners still fire at the same point in the sequence — awaiting inline first would have delayed every listener behind the handler, which is a change I did not want to smuggle in alongside this. applySettingLocally becomes async and set/setMany await it; addSetting keeps it detached via void, which is correct now that the registration replay no longer writes anything.

Your observation that this PR is what makes awaiting safe is the part I had not connected, and it is now in the commit message. Under the old ordering a handler writing back to its own key compared newValue against an uncommitted oldValue, so the guard did not guard and awaiting would have recursed. Committing first is the precondition.

Pinned with a unit test so the ordering cannot silently regress:

expect(order).toEqual(['onChange', 'storeSetting'])

On rollback semantics: fair, and I had reached the same conclusion from the other direction — dispatchChange goes through EventTarget.dispatchEvent, which never propagates listener exceptions to the caller, and every current handler is async or non-throwing, so there is no reachable trigger. Worth noting setMany could already leave earlier keys locally mutated on a sync throw before this PR, so the contract was never all-or-nothing. I have not added rollback for a path with no trigger, but the divergence is now called out in the docblock rather than left implicit.

import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import { breakpointsTailwind } from '@vueuse/core'

const CANVAS_NAVIGATION_PRESETS: Record<string, Partial<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.

Two type nits, both of which cost the map the safety it looks like it has.

Record<string, Partial<Settings>> makes the runtime guard invisible to TS. With strict: true but no noUncheckedIndexedAccess, CANVAS_NAVIGATION_PRESETS[val as string] is typed Partial<Settings> — never undefined. So if (!preset) return on line 204, which is load-bearing (it is the entire handling of custom and of any unknown value), reads to the compiler as dead code. Prefer:

const CANVAS_NAVIGATION_PRESETS: Record<string, Partial<Settings> | undefined> = { ... }

or key it on the literal union and look up through a helper, so the narrowing is real.

Object.fromEntries(Object.entries(preset)…) erases to Record<string, any>. Object.entries on Partial<Settings> falls through to the entries(o: {}): [string, any][] overload, so unset is { [k: string]: any } and the setMany(unset) call site accepts anything. The Partial<Settings> annotation on the map buys nothing past this line — misspell 'Comfy.Canvas.MouseWheelScrol' in the preset and it still compiles and still writes a junk key to the user's settings file. A typed accumulate keeps it honest:

const unset: Partial<Settings> = {}
for (const id of Object.keys(preset) as (keyof Settings)[]) {
  if (!settingStore.exists(id)) Object.assign(unset, { [id]: preset[id] })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both correct, both fixed.

Invisible guard. The map is now Record<string, Partial<Settings> | undefined> in constants/canvasNavigation.ts, so if (!preset) return narrows for real instead of reading as dead code to the compiler. That guard carries the entire handling of custom and of any unknown value, so having TS treat it as unreachable was the worst place to lose type information.

Object.fromEntries erasure. Replaced with the typed accumulate you suggested:

const unset: Partial<Settings> = {}
for (const id of Object.keys(preset) as (keyof Settings)[]) {
  if (settingValues.value[id] === undefined) {
    Object.assign(unset, { [id]: preset[id] })
  }
}

Your misspelling test is the convincing argument — 'Comfy.Canvas.MouseWheelScrol' previously compiled and would have written a junk key into the user's settings file, which is the sort of thing that survives forever because nothing ever reads it. The annotation on the map now actually reaches the call site.

Both the map and the migration also gained a note that custom is deliberately absent, since its absence is what makes the no-op work and is otherwise an inviting thing to "complete".


// 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.

@benjcooley benjcooley 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.

Careful diagnosis and a genuinely good root-cause writeup — the two-cause analysis is correct, and I confirmed both against the code. The applySettingLocally reorder is the right call and the unit test pins the contract properly. But I don't think the second half of the fix is in the right place, and one claim in the description doesn't hold.

Blocking

1. The migration is implemented in an onChange registration replay instead of the load() migration hook the codebase already has for this. (coreSettings.ts L215-222)

settingStore.ts already does this exact shape of thing in migrateZoomThresholdToFontSize(), awaited from load() before any setting is registered — "old key stored, new key isn't → derive it and persist." Putting it in onChange instead buys three problems that hook doesn't have:

  • Un-awaited network write, unhandled rejection. onChange() discards the promise from setting.onChange(...), so setManyPOST /settings runs detached with no catch. Backend unreachable or write-rejected → unhandled rejection during bootstrap, every load, for exactly the affected profiles. Before this PR, registration replay bailed at if (!oldValue) return and never touched the network.
  • Silent dependence on CORE_SETTINGS array order. It only works because NavigationMode is registered before the two overrides. Reorder the array and the demotion returns immediately. Nothing states the invariant.
  • The writes land while settingsById has no entry for those keys, so they skip dispatchChange and skip telemetry unlike every other settings write. Right answer by accident.

A migrateCanvasNavigationOverrides() called from load() is awaited, order-independent, unit-testable without Playwright, and lets onChange go back to meaning "the user changed something."

2. exists() re-arms the bug the moment the default flips. (coreSettings.ts L215)

Gating on stored rather than effective mode is safe only because NavigationMode's default (legacy) happens to agree with the override defaults (panning/zoom). Ship defaultValue: 'standard' or a newer defaultsByInstallVersion entry — an obvious near-term move for an option labelled "Standard (New)" — and every fresh profile gets demoted to custom on first load with no interaction. Derive from settingStore.get, or add the invariant test.

Please correct the description

"Affected profiles self-heal on next load" is not true for the profiles that reported this. The cause-2 demotion persists custom to the server and writes only the mode, never the overrides. So anyone who has already loaded a 1.27.4+ build is sitting on NavigationMode: "custom" with the overrides absent — and CANVAS_NAVIGATION_PRESETS has no custom entry, so this PR no-ops on them. Their original choice is genuinely unrecoverable (the demotion overwrote the only record), so I'm not asking for code here — but the description, release notes, and FE-1503 shouldn't say the reporters are fixed. They have to re-pick the mode by hand, which will now stick. Please also add that profile to the test matrix so the no-op is pinned as deliberate.

Non-blocking

  • The two remaining writes still race. onChange isn't awaited, so POST /settings (overrides) and POST /settings/NavigationMode are in flight simultaneously against the non-atomic backend you documented. "One write per key" is true about count, but if the overrides write wins the read, the mode is lost and the next load demotes to custom — the original symptom, at lower probability. await setting.onChange(...) serialises them, and this PR is what makes awaiting safe: committing before the handler runs is what makes the newValue === oldValue guard actually guard against re-entrancy. Worth claiming as a second benefit.
  • Rollback semantics changed — a synchronous handler that throws now leaves the value committed locally with no server write. Low severity, but the new docblock should say so.
  • Type nitsRecord<string, Partial<Settings>> makes the load-bearing if (!preset) return invisible to TS; Object.fromEntries(Object.entries(...)) erases unset to Record<string, any>, so a typo'd preset key compiles and writes junk to the settings file.
  • Test gaps — the custom profile, the partial-override case (the .filter never runs with only one missing), the custom short-circuit; and "applies the stored preset to the overrides" only reads the in-memory store, so it passes even if the healing POST never lands. A reloadAndWaitForApp() would make it the persistence test it claims to be.

What I liked

The settingStore.test.ts case is the right test — it pins the ordering as a store-wide contract rather than a canvas quirk, which is where the bug actually lived. Catching that muting the handlers would fix the label but not the behaviour, and saying so in the description, is exactly right. And the follow-up about deriving NavigationMode from the two overrides is the real fix — this cluster has three keys for two bits of state, and every bug in it traces to that.


onChange(settingsById.value[key], newValue, oldValue)
const typedNewValue = newValue as Settings[K]
settingValues.value[key] = typedNewValue

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.

Two things a full sweep of the blast radius turned up — one argues for the change, one is an unlisted breaking change.

In favour: this makes set/setMany consistent with addSetting, which already commits first.

// addSetting, further down this file
settingsById.value[setting.id] = setting
if (settingValues.value[setting.id] !== undefined) { ... }
onChange(setting, get(setting.id), undefined)   // ← already post-commit

Registration has always fired onChange against a committed value; only set/setMany did it backwards. That is a stronger framing than "fixes the cascade" and belongs in the docblock — it is why the old ordering was a bug rather than a choice. Worth noting too that const oldValue = get(key) is still computed before the swap, so the (newValue, oldValue) arguments and the newValue === oldValue short-circuit are byte-for-byte unchanged; only re-entrant reads move.

Against: this silently changes behaviour for third-party extensions, and the PR does not mention it.

onChange() also fires app.ui.settings.dispatchChange(...), which this file explicitly documents as a compat shim:

// Backward compatibility with old settings dialog.
// Some extensions still listens event emitted by the old settings dialog.

Any extension listening on '<id>.change' that re-reads app.ui.settings.getSettingValue(id) inside the handler — rather than using event.detail.value — now observes the new value where it previously observed the old one. There is an in-repo instance of exactly that shape at src/scripts/ui/components/button.ts:133-145:

const settingUpdated = () => {
  this.hidden =
    app.ui.settings.getSettingValue(visibilitySetting.id) !== visibilitySetting.showValue
}
app.ui.settings.addEventListener(visibilitySetting.id + '.change', settingUpdated)

That one is a fix (it was reading stale and rendering one change behind, though nothing in-repo currently passes visibilitySetting, so the practical impact is custom-node code). But it demonstrates the pattern is real and out there. detail.oldValue consumers are unaffected.

I do not think this should block — the new behaviour is the correct one and the old one was indefensible. But a store-wide ordering change on a documented extension compat surface deserves a line in the PR description and a changelog note, rather than arriving as a side effect of a canvas-settings bugfix.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The addSetting framing is much better than mine and I have taken it into the docblock — registration always fired onChange post-commit, so set/setMany were the inconsistent pair, not the other way round. That reframes the old ordering as a straightforward inconsistency rather than a defensible design, which is a more useful thing for the next reader to find. Your note that const oldValue = get(key) is still computed before the swap is worth having on the record too: the (newValue, oldValue) arguments and the short-circuit are byte-identical, only re-entrant reads moved.

On the extension compat surface — you are right that it deserved calling out rather than arriving as a side effect, and thank you for actually finding the in-repo instance at button.ts:133-145. That is a much more concrete basis than my own reasoning, which had stopped at "dispatchEvent cannot throw into the caller" and had not considered listeners that re-read rather than using event.detail.

Two things I did as a result:

  • Kept dispatchChange firing at its original point in the sequence, before the handler is awaited (see my reply on the settingStore.ts thread). So listeners fire at the same time relative to the value commit as they did before this PR — the only change for them is which value a re-read observes, not when they run.
  • Left the behaviour as-is, since as you say the new reading is the correct one and button.ts was rendering one change behind.

I cannot edit the PR description with my tooling, so I will get the ordering change and its extension-visible effect written into the description and flagged for the changelog rather than leaving it implicit — along with the correction on self-healing from the other thread. Flagging both to the team now so they land in the release notes and not just in this conversation.

Addresses review on #14716.

Healing a pre-1.27.4 profile from the mode's own onChange registration
replay had three problems the load() hook does not: the write was
detached and unhandled because onChange() discarded the returned promise,
it silently depended on CORE_SETTINGS registering the mode before the two
overrides, and it wrote keys whose settingsById entries did not exist yet,
so those writes skipped dispatchChange and telemetry.

migrateCanvasNavigationOverrides() now runs from load() alongside the
existing zoom-threshold migration, before any setting is registered, so
onChange goes back to meaning "the user changed something". The preset
pairs move to constants/canvasNavigation.ts so both call sites share one
definition without a cycle.

Also await the handler in onChange() so a cascading write completes before
the caller writes its own key. The two were racing against the backend's
non-atomic read-modify-write, which left the fix probabilistic; committing
the value first is what makes awaiting safe, since the newValue === oldValue
guard now compares against committed state.

Tests: migration unit coverage for the stored-preset, partial-override,
custom and unstored profiles; an invariant test that the override defaults
describe whichever mode a fresh profile resolves to, so flipping that
default trips a test instead of demoting every new install; and E2E cover
for the already-demoted profile, the partial profile, the custom no-op,
plus a reload so the override assertions test persistence.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@fennuck-bot

fennuck-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

That note is the auto-generated "GitHub couldn't provide an incremental comparison, running a full review instead" message — it carries no findings, so there's nothing to action from it directly. It fired because @DrJKL merged main into this branch in fc3402f, which gave CodeRabbit a diff base it couldn't compare incrementally.

Rather than just say "no-op", I re-verified the branch from scratch against the merged state, since that merge pulled in a lot (vite/vitest/vue bumps, the image compositor feature, ~55k lines).

The merge did not touch this PR's changes. git diff origin/main...HEAD is still the same 7 files, 348 insertions.

Verification on the merged head

Gate Result
settingStore.test.ts + canvasNavigation.test.ts 47/47 pass
pnpm typecheck exit 0
pnpm typecheck:browser exit 0
oxlint --type-aware, eslint, oxfmt --check clean
canvasSettings.spec.ts — NavigationMode 6/6 pass
canvasSettings.spec.ts — full file 14 passed on this branch vs 8 on clean main, with an identical set of 5 pre-existing failures (canvas-drag + screenshot baselines)

The 5 failures are unrelated to this PR — I checked out d11f308 (clean main) and reproduced the exact same 5 by name. All 59 CI check runs on fc3402f are green (50 success, 9 skipped).

End-to-end re-check

I also reproduced the original bug and the fix by hand against a local backend, seeding a pre-1.27.4 profile (mode stored, both overrides absent) and loading the page with no user interaction:

seeded:  NavigationMode = standard, LeftMouseClickBehavior = <absent>, MouseWheelScroll = <absent>

On clean main, the stored mode is silently rewritten on load:

after load:  NavigationMode = custom          ← the bug

On this branch it holds, and the overrides it implies are materialised:

after load:  NavigationMode = standard
             LeftMouseClickBehavior = select
             MouseWheelScroll = panning

Same seeded profile, same settings pane, both screenshots below.

One correction to the PR description

The Verification section has two broken image placeholders (https://placeholder-before / https://placeholder-after) that never got substituted — they render as broken images. The real screenshots are the two at the bottom under Screenshots, and the pair below supersedes the placeholders. @DrJKL, worth deleting those two lines from the description since I can't edit it from here.

No code changes in this round — the branch was already correct and CodeRabbit's standing review on it is APPROVED.

Screenshots

Before (clean main): a pre-1.27.4 profile storing NavigationMode=standard loads with Navigation Mode silently demoted to Custom

After (this branch): the same seeded profile loads with Navigation Mode retained as Standard (New)

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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 `@src/platform/settings/constants/canvasNavigation.test.ts`:
- Around line 25-37: Update the two Navigation Mode tests around
settingById(NAV) to narrow defaultValue and each defaultsByInstallVersion value
to a string before indexing CANVAS_NAVIGATION_PRESETS, resolving callable
defaults using the same behavior as the setting store. Remove both as string
assertions and preserve the existing preset comparisons.

In `@src/platform/settings/constants/coreSettings.ts`:
- Around line 191-196: Update the onChange callback to verify that val has type
string before indexing CANVAS_NAVIGATION_PRESETS; return early when it is not a
string, while preserving the existing old-value and missing-preset checks.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 75484cff-f64f-485b-a060-00739421875f

📥 Commits

Reviewing files that changed from the base of the PR and between c4f9b91 and 08e054c.

📒 Files selected for processing (7)
  • browser_tests/tests/canvasSettings.spec.ts
  • src/platform/settings/constants/canvasNavigation.test.ts
  • src/platform/settings/constants/canvasNavigation.ts
  • src/platform/settings/constants/coreSettings.ts
  • src/platform/settings/settingStore.test.ts
  • src/platform/settings/settingStore.ts
  • src/platform/settings/types.ts

Comment thread src/platform/settings/constants/canvasNavigation.test.ts Outdated
Comment thread src/platform/settings/constants/coreSettings.ts
Replace the 'as string' assertions on the unknown onChange value and on the
Navigation Mode defaults with typeof narrowing, and resolve callable defaults
the way the setting store does.
coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 12, 2026
@github-actions github-actions Bot added the risk:R2 PR risk grade (advisory shadow check; grader-owned) label Aug 13, 2026
onChange(settingsById.value[key], newValue, oldValue)
const typedNewValue = newValue as Settings[K]
settingValues.value[key] = typedNewValue
await onChange(settingsById.value[key], newValue, oldValue)

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.

Awaiting the cascade was my suggestion and the ordering comment in onChange() is exactly right — but it has a consequence worth handling explicitly.

set() is now:

const applied = await applySettingLocally(key, value)   // ← awaits onChange, can reject
if (applied === undefined) return
await api.storeSetting(key, applied.newValue)           // ← skipped on rejection

A rejecting handler now aborts the parent write. Before, the promise was dropped and api.storeSetting ran regardless. Concretely: pick Navigation Mode standard, have the cascaded storeSettings fail, and the mode is applied in memory but never persisted — silently reverting on next load. In setMany it is worse: keys applied earlier in the batch are already committed in memory when a later handler throws, and the single api.storeSettings for the whole batch never fires.

The sharper edge is third-party: SettingParams.onChange is public API via ComfyExtension.settings. One custom node with a throwing handler now blocks the user's own setting from ever being saved. That is a bad failure mode to hand to extension authors.

You can keep the serialisation you need — which is the whole point of the await — without the coupling:

await Promise.resolve(handled).catch((e) => {
  console.error(`[settings] onChange handler for ${setting?.id} failed`, e)
})

Ordering is preserved (the write still waits for the cascade to finish), but a broken handler degrades to a logged error instead of silently discarding the user's change.

}) => {
// Reload first so this asserts what reached the server, not just what
// the migration put in the in-memory store.
await comfyPage.workflow.reloadAndWaitForApp()

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.

This addresses my earlier note, but the reload does not actually buy what the comment claims:

// Reload first so this asserts what reached the server, not just what
// the migration put in the in-memory store.
await comfyPage.workflow.reloadAndWaitForApp()

Comfy.Canvas.NavigationMode: 'standard' is still on the server after the reload — it is the seeded value, and nothing in this flow removes it. So on the second load migrateCanvasNavigationOverrides runs again, finds the overrides missing (if the POST failed), and re-derives select/panning into settingValues from scratch. getSetting then returns the right answer whether or not storeSettings ever succeeded. The migration's idempotence is exactly what makes the assertion blind to persistence.

So the test is fine as a "the repair is stable across reloads" test, but the comment asserts a guarantee it does not provide — and given the uncaught-write issue I flagged in settingStore.ts, this is the test that would otherwise have caught it. Either read the server directly:

const settings = await comfyPage.request.get('/api/settings').then(r => r.json())
expect(settings['Comfy.Canvas.LeftMouseClickBehavior']).toBe('select')

or assert the POST /api/settings fired with the expected body (the pattern you already use in "picking a preset never persists custom"). Failing that, please reword the comment so it does not claim server-side coverage.

defaultValue: TValue | (() => TValue)
defaultsByInstallVersion?: Record<`${number}.${number}.${number}`, TValue>
onChange?(newValue: TValue, oldValue?: TValue): void
onChange?(newValue: TValue, oldValue?: TValue): void | Promise<void>

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.

Correct and necessary now that the result is awaited — but note this is a source-breaking change on published API (SettingParams reaches extension authors through ComfyExtension.settings), and the PR does not mention it.

TS's void-returning-function assignability exemption — which lets you assign () => T to a slot typed () => void for any T — applies only when the target return type is exactly void. void | Promise<void> is a union, so the exemption is gone. Any TS extension written as an expression-bodied arrow that happens to return something now fails to compile:

onChange: (v) => this.values.push(v)        // push returns number — was fine, now errors
onChange: (v) => (this.enabled = v)         // assignment expression — same

This already bit inside this PR: the handler in settingStore.test.ts had to be rewritten from an expression body to a block body to satisfy the new signature. That is the exact diff every affected extension author will have to make, without the benefit of seeing this PR.

Low severity in practice — most custom nodes ship JS, not TS — so I am not blocking on it. But it deserves a line in the description and a changelog entry, since silent compile breaks in third-party code are the kind of thing that gets reported as "the frontend broke my extension" three releases later. If you would rather avoid the break entirely, keeping the field typed void and doing await Promise.resolve(setting.onChange(...)) at the call site gets you the same runtime serialisation with no signature change.

Comment thread src/platform/settings/settingStore.ts Outdated
if (!Object.keys(unset).length) return

Object.assign(settingValues.value, unset)
await api.storeSettings(unset)

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.

Blocking: an uncaught write here stops the app from booting.

migrateCanvasNavigationOverrides is awaited inside the useAsyncState loader, so a rejecting api.storeSettings lands in settingStore.error. Downstream, GraphCanvas.vue:512-521:

await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)

if (settingsError.value) {
  if (settingsError.value instanceof UnauthorizedError) { ...reload...; return }
  throw settingsError.value          // ← boot aborts here
}

CORE_SETTINGS.forEach(settingStore.addSetting)   // ← never runs

(settingsError is storeToRefs(settingStore).error, GraphCanvas.vue:228.) So one transient 5xx or offline POST turns a cosmetic settings repair into an app that will not start. api.getSettings immediately above is wrapped in retry(...) precisely because this loader is boot-critical; this new write has neither a retry nor a catch.

I know migrateZoomThresholdToFontSize has the same uncaught shape, so this is a pre-existing hazard class rather than one you invented. It is still worth blocking on, because this PR changes the exposure by orders of magnitude: that migration only fires for profiles carrying the deprecated zoom-threshold key, a set that only shrinks, whereas this one fires for every profile with a stored preset and missing overrides — by your own analysis, everyone who picked a mode between 1.25.0 and 1.27.4. A rare failure mode becomes a common one.

The fix is small and self-correcting. Object.assign(settingValues.value, unset) has already run so the session is correct either way, and the server still lacks the overrides so the next load simply retries:

try {
  await api.storeSettings(unset)
} catch (e) {
  console.warn('Failed to persist canvas navigation overrides; retrying next load', e)
}

Worth giving migrateZoomThresholdToFontSize the same treatment while you are in here.

@benjcooley benjcooley 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.

Re-reviewed at 002f64d. You addressed all three of my blocking points properly, and not superficially:

  • the repair moved into migrateCanvasNavigationOverrides() in load(), awaited, ahead of registration — so the CORE_SETTINGS ordering dependency and the settingsById-not-yet-populated weirdness are both gone;
  • onChange is awaited, with a comment explaining why the handler starts before dispatchChange but is awaited after it — that is a subtle ordering constraint and documenting it was the right call;
  • the presets moved to canvasNavigation.ts with real unit tests, Partial<Settings> | undefined restores the narrowing so if (!preset) return is honest, and the unset accumulation is now typed;
  • the custom no-op is explicitly documented and tested rather than left implicit, and the description now says affected profiles need to re-pick.

I traced the state machine again at this head — standard↔legacy↔custom, preset picks, partial profiles, registration replay — and found no remaining path where the mode is spuriously demoted. The fix is correct. CI is green (29 pass).

One thing stops me approving.

Blocking

The migration's api.storeSettings is uncaught inside the boot-critical loader (settingStore.ts:341). It rejects → settingStore.error is set → GraphCanvas.vue:521 does throw settingsError.valueCORE_SETTINGS.forEach(settingStore.addSetting) never runs. A transient POST failure turns a cosmetic settings repair into an app that will not start.

migrateZoomThresholdToFontSize has the same shape, so the hazard predates you — but this PR takes it from "fires for profiles with a deprecated zoom key" to "fires for everyone who picked a mode between 1.25.0 and 1.27.4," which is the whole population this PR exists to serve. A try/catch with a warn is enough: the in-memory assign has already happened, and the server still lacks the overrides, so the next load retries by itself.

Non-blocking, but please look

  • settingStore.ts:151 — awaiting the cascade (my suggestion) means a rejecting handler now aborts the parent write, so the setting is applied in memory and never persisted. Worse for setMany, where earlier keys in the batch are already committed. And since onChange is public API via ComfyExtension.settings, one custom node with a throwing handler can block the user's own setting from saving. await Promise.resolve(handled).catch(logIt) keeps the serialisation you need without the coupling.
  • canvasSettings.spec.ts:265 — the reload does not make this a persistence test. NavigationMode is still stored server-side, so the second load just re-runs the migration and re-derives select/panning in memory; the assertion passes whether or not the POST landed. The migration's idempotence is what blinds it. Notably this is the test that would otherwise have caught the blocker above. Read /api/settings directly, or reword the comment.
  • types.ts:43voidvoid | Promise<void> drops TS's void-return assignability exemption on a published interface, so onChange: (v) => arr.push(v) stops compiling for TS extension authors. It already bit inside this PR: the test handler in settingStore.test.ts had to be rewritten to a block body. Low real-world impact, but it wants a changelog line.

Fix the boot path and I'm happy to approve — the rest is polish and I'd take it as follow-up. Nice iteration on this one; the reasoning in the description is genuinely better than most bugfix PRs I read.

@DrJKL
DrJKL requested a review from benjcooley August 14, 2026 00:24
The canvas navigation override migration runs inside the boot-critical
settings loader, so a rejected write surfaced as settingStore.error,
which GraphCanvas rethrows before registering any core setting. A
transient POST failure turned a cosmetic repair into an app that would
not start, for every profile that picked a mode between 1.25.0 and
1.27.4. Both migrations now log and continue; the value is already
applied in memory and the server still holds the un-migrated state, so
the next load retries.

Awaiting onChange also meant a failing handler aborted the caller's own
write. onChange is extension-facing public API, so one custom node could
stop the user's setting from ever being saved. Handler failures are now
logged and isolated, covering synchronous throws as well as rejections.

The persistence e2e test read the in-memory store, which the migration
re-derives on every load, so it passed whether or not the write landed.
It now reads the server through a new getPersistedSetting helper.
Awaiting onChange means a slow third-party handler lets a later change
to the same key land first. The stalled call then resumed and persisted
its own stale value, leaving the server disagreeing with the store and
the setting reverting on reload — the failure this branch set out to fix.
Each apply now takes a per-key ticket and skips its write and telemetry
if a newer change claimed the key while its handler ran.

Handler faults log at warn rather than error, matching wrapListener:
RUM collects console.error, so reporting third-party faults there would
relocate the noise the isolation exists to remove.

// Handlers are awaited, so a slow one lets a later change to this key land
// first. That change owns the value now; persisting ours would revert it.
if (latestWrite.get(key) !== write) return undefined

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.

Non-blocking — this is the right guard, but the title claim ("drop settings writes a newer change has superseded") is stronger than what it delivers, and it's worth writing that down.

The counter closes the handler-await window: a slow onChange can no longer let a later change land first and then have the stale value written on top. Verified against the cascade — set(nav,'standard') bumps nav to 1, the cascade bumps left/wheel independently, and nothing demotes.

What it does not close is the network window. Both writes can pass the guard and still reorder in flight:

set(key,'A')  counter=1  guard passes (no concurrent set yet)  POST A ──── slow ────┐
set(key,'B')  counter=2  guard passes                          POST B ── fast ──┐   │
                                                                                B   A   ← server ends on A

Memory holds B, server holds A. Re-checking the counter immediately before api.storeSetting doesn't help — A already passed. Closing it properly means serialising per key (chain each key's write on the previous one's promise) or having the backend accept a version.

This is pre-existing — on main applySettingLocally was sync, so both writes were issued the same way — and it's the same hazard as the non-atomic app_settings.py read-modify-write you already list under Follow-ups. I'd just extend that bullet to say the client can reorder too, so nobody reads this guard as "concurrent writes to one key are now safe."

// Store the migrated setting
await api.storeSetting(newKey, clampedFontSize)
await api.storeSetting(oldKey, undefined)
await persistMigration(async () => {

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.

Non-blocking, and I'd take it as a follow-up: the "retrying on next load" contract in persistMigration's docblock doesn't hold for this caller, because these are two writes, not one.

If storeSetting(newKey, …) succeeds and storeSetting(oldKey, undefined) fails, the server ends up holding both keys. Next load re-enters with newKey !== undefined, so the guard on line 401 skips the migration — and the stale LowQualityRenderingZoomThreshold is never cleaned up. No retry, just a permanent orphan.

Harmless in effect (nothing reads the old key once the new one exists) but it means the failure is silent and terminal rather than deferred. The nav migration doesn't have this shape — one storeSettings call, all-or-nothing, so its comment is accurate there.

Cheapest fix if you want it: hoist the delete into its own persistMigration and drop the newKey === undefined half of the guard, or just accept the orphan and narrow the comment to "the nav migration retries; this one may leave the old key behind."

@benjcooley benjcooley 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.

Approving — the blocker is fixed, and fixed properly

Re-reviewed at c969f53. Both new commits land my outstanding findings, and I verified each against the code rather than taking the commit messages at face value.

What I checked

1. Boot-critical uncaught write — fixed, and the pre-existing case with it.
persistMigration() wraps both migrations. I'd flagged only migrateCanvasNavigationOverrides; you also wrapped migrateZoomThresholdToFontSize, which had the identical shape on main and would have taken the app down the same way. That was the right call — it wasn't your regression, but it was live. The unit tests assert the thing that actually matters (store.error undefined, isReady true), not just that a warning was logged.

2. onChange rejection discarding the user's own write — fixed.
callHandler catches both a rejected promise and a synchronous throw, and the it.for covers both. Importantly, it swallows only the handler leg — api.storeSetting still rejects out of set(), so scripts/ui/settings.ts:87's errorSaveSetting toast still fires on a real save failure. I swept every SettingParams.onChange in the repo (8 handlers, 6 distinct functions across coreSettings.ts, electronAdapter.ts, slotDefaults.ts): none validates its input, none throws deliberately, none is a veto-by-throw. Nothing regresses. warn over error matches ComfyApi.wrapListener — checked, the precedent is real.

3. The reload test — fixed, and the fix is better than what I asked for.
getPersistedSetting goes through api.getSettings(), which I confirmed is an uncached GET /settings. That is a genuine server assertion; the reload never was. Comment now says what the test does.

4. defaultValue flip re-arming the bug (my round-1 finding) — canvasNavigation.test.ts is a better answer than the one I proposed. Asserting the relationship between NavigationMode's default and the override defaults, including defaultsByInstallVersion, means whoever flips the default to standard trips a red test instead of shipping the regression. That's the finding turned into a permanent guard.

State machine

Re-traced end to end at head. The demotion paths are closed for the right reason, not by suppression:

  • Preset cascadeset(nav,'standard') commits nav before awaiting, so left/wheel handlers read 'standard' and both match. One write per key, no 'custom'.
  • Two mismatched overrides at registrationLEFT's replay commits nav='custom' synchronously before its first await, so WHEEL's replay reads 'custom' and no-ops. No double write, and it holds because of the commit-before-onChange reorder, not by luck.
  • Partial profile ({nav: standard, wheel: zoom}) — fills left, keeps zoom, demotes to custom. Correct: select+zoom genuinely isn't a preset, and you found that one yourself when the test failed.
  • custom — no preset, no cascade, overrides untouched.

CI green including the test job and all 16 Playwright shards.

Non-blocking, none of it gating

  • Two inline notes: the supersede counter closes the handler-await window but not the network-reorder one (worth folding into your existing app_settings.py follow-up bullet so nobody reads it as "concurrent same-key writes are safe now"), and the zoom migration's two-write shape can orphan the old key on partial failure.
  • The description has drifted from the code and it feeds release notes. Two spots: "On the registration replay, a stored preset now supplies the overrides" — it runs from load() now, per your own fix; and "affected profiles self-heal on next load" — you agreed in this thread that already-demoted profiles do not, and said you'd corrected it, but the body still says it. Users who hit FE-1503 still have to re-pick the mode by hand; support should know that.
  • SettingParams.onChange widening to void | Promise<void> is still a source break for TS extension authors — the () => T void-exemption disappears once the target isn't exactly void, which is why you had to rewrite your own test handler to a block body. It needs a changelog line. Not this PR's job to fix, but it shouldn't ship unannounced.

Good change. The root cause is genuinely fixed rather than papered over, and the follow-ups section is honest about what's left.

@DrJKL
DrJKL added this pull request to the merge queue Aug 14, 2026
Merged via the queue into main with commit 718db8b Aug 14, 2026
66 checks passed
@DrJKL
DrJKL deleted the glary/fix-canvas-navigation-mode-reset branch August 14, 2026 17:33

@marawan206 marawan206 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.

Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:R2 PR risk grade (advisory shadow check; grader-owned) size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants