Skip to content

fix(savebar): save all changed sections in a group with one click (#3552)#3558

Merged
Yeraze merged 2 commits into
mainfrom
feat/3552-settings-save-all
Jun 19, 2026
Merged

fix(savebar): save all changed sections in a group with one click (#3552)#3558
Yeraze merged 2 commits into
mainfrom
feat/3552-settings-save-all

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #3552 — editing settings across multiple sections before clicking Save only persisted some changes; the others silently reverted.

Root cause: the unified SaveBar only called effectiveSection.onSave() — the single active section. Any other section with unsaved changes was never saved.

Fix

Introduce an optional section group. Sections sharing a group are saved (and dismissed) together by a single "Save All" action. The group is supplied via a <SaveBarGroup id="…"> provider wrapping a render region, so sections inherit it from context — no need to edit each of the ~25 section files.

  • SaveBarContext.tsxgroup? on SaveBarSection; new SaveBarGroup provider + useSaveBarGroup hook.
  • useSaveBar.ts — sections inherit the nearest SaveBarGroup (explicit group option still wins; pass null to opt out).
  • SaveBar.tsx — grouped active section ⇒ Save/Dismiss apply to every changed section in the group; saves run sequentially, each guarded so one failure doesn't abort the batch. Shows "Save All" with the changed-section count in the message text.
  • Wrapped Settings + Automation (App.tsx), the Global Settings page, and MeshCore Automations (MeshCorePage.tsx) in SaveBarGroup.
  • en.jsonsavebar.save_all / savebar.save_all_changes.

Device Configuration is intentionally left ungrouped — each section is a separate device admin write, so it keeps the per-section tab behavior, exactly as before.

Note: the Settings tab itself registers two SaveBar sections — SettingsTab and the nested PositionEstimationSection — which is the Settings-tab manifestation of this bug. Wrapping <SettingsTab> batches both.

Behavior change

  • Grouped (Settings / Automation / MeshCore Automations): one click saves all changed sections.
  • Ungrouped (Device Configuration): unchanged — per-section save via tabs.

Testing

  • New SaveBar.test.tsx (4 tests): grouped batch-save, batch-dismiss, resilience when one save throws, and ungrouped sections still save only the active section.
  • Full suite: 6876 passed, 0 failed (2327 suites, 0 failed).

🤖 Generated with Claude Code

)

The unified SaveBar only saved the active section, so editing multiple
sections before clicking Save persisted just one — the others appeared to
silently revert.

Introduce an optional section "group": sections sharing a group are saved
and dismissed together by a single "Save All" action. The group is supplied
via a <SaveBarGroup> provider that wraps a render region, so sections inherit
it from context without per-section edits.

Settings, Automation, the Global Settings page, and the MeshCore Automations
view are now grouped (the Settings tab itself contains two registered
sections — SettingsTab and the nested PositionEstimationSection — which is the
Settings manifestation of the bug). Device Configuration is intentionally left
ungrouped so each section keeps saving individually, since each is a separate
device admin write.

Batch saves run sequentially and guard each section so one failure doesn't
abort the rest. Behavior for ungrouped sections is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVGxkuD4Fwa2JGVim8ZeVj
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(savebar): save all changed sections in a group with one click (#3552)

  • Read changed files
  • Review code quality and best practices
  • Check for potential bugs
  • Evaluate performance considerations
  • Assess security concerns
  • Evaluate test coverage
  • Post final review

Overall this is a well-scoped and clean fix. The context-propagation approach (wrap a region in <SaveBarGroup>, sections inherit via useSaveBarGroup()) is elegant — it avoids touching every leaf section file. The PR description is accurate and the intentional carve-out for Device Configuration is clearly documented.

Issues found

1. handleSave doesn't reflect isSaving state during the batch (SaveBar.tsx:45–56)

The sequential loop calls each section's onSave(), but isSaving is tracked in per-section state managed by each section's own useSaveBar call. During the batch, the SaveBar reads anySaving = groupSections.some(s => s.isSaving) from a snapshot of sections in context. The buttons are correctly disabled while anySaving is true only if each section updates its own isSaving in context before onSave() resolves. If any section's isSaving update is asynchronous/batched by React, there is a window where the Save button is re-enabled between sequential saves. This is a timing edge case but could allow double-clicks mid-batch.

2. Group resolution uses null-coalescing in a non-obvious way (useSaveBar.ts:27)

const group = options.group !== undefined ? options.group ?? undefined : inheritedGroup ?? undefined;

The double-null-coalescing is hard to read. When options.group is null (opt-out), options.group ?? undefined evaluates to undefined, so the section gets no group — which is the intended behavior. But the intent isn't immediately clear from reading the expression. A brief comment (or a two-branch expression) would help future maintainers. Minor readability nit.

3. showTabs logic only tests effectiveSection.group but tabs render all sectionsWithChanges (SaveBar.tsx:70, 78)

const showTabs = !effectiveSection.group && sectionsWithChanges.length > 1;

If sections from mixed groups and ungrouped sections coexist, showTabs would be false for any grouped effective section, but the ungrouped sections would silently lose their tabs. This is likely fine today given how the app is structured (the providers are distinct), but is worth noting as a future edge case.

4. Badge renders in wrong position for batch mode (SaveBar.tsx:117–121)

{showTabs && (
  <div className="save-bar-badge">
    {sectionsWithChanges.length}
  </div>
)}

The badge only renders when showTabs is true (ungrouped multi-section). In batch mode the count appears in the translated string (save_all_changes: "Save unsaved changes in {{count}} sections") but there's no visual badge. The PR description says "Shows 'Save All' + a count badge" — but the badge isn't shown for the grouped path. This may be intentional (the count is in the message text), but the PR description is slightly misleading.

5. MeshCorePage.tsx creates a nested SaveBarProvider for the automations view (MeshCorePage.tsx:171–179)

{view === 'automations' && (
  <SaveBarProvider>
    <SaveBarGroup id="meshcore-automation">
      <MeshCoreAutomationsView ... />
    </SaveBarGroup>
    <SaveBar />
  </SaveBarProvider>
)}

This works, but it means MeshCore automations use a completely separate SaveBarProvider from the rest of MeshCorePage. If other MeshCore views (e.g. MeshCoreSettingsView) ever register SaveBar sections, they won't be visible to this nested provider — and vice versa. This is consistent with today's behavior but could cause confusion. An alternative would be to have a single SaveBarProvider at MeshCorePage level (currently absent). Low priority but worth documenting.

Minor nits

  • GlobalSettingsPage.tsx:87–139: <SaveBarGroup id="settings"> uses the same group id ("settings") as the App.tsx settings tab group. They're under different SaveBarProvider trees so this doesn't cause collisions, but it could confuse someone searching for which group id governs which area.

  • The test at SaveBar.test.tsx:64 uses findByText('savebar.save_all') — it relies on the i18n key being returned as-is (no translation in test environment). This is intentional and common in React Testing Library tests without a real i18n setup, but adding a comment would make it explicit.

  • SaveBar.test.tsx:144: The assertion expect(saveA.mock.calls.length + saveB.mock.calls.length).toBe(1) is correct but slightly fragile — it passes even if neither mock was called (0 + 0 = 0 ≠ 1, actually that would fail). It would be slightly clearer to assert toHaveBeenCalledTimes(1) on whichever section is expected to be auto-selected (section "a" since it's first registered).

Test coverage assessment

The 4 tests cover the core regression scenarios well:

  • ✅ Batch save (both sections called)
  • ✅ Batch dismiss (both sections called)
  • ✅ Error resilience (continues after one throws)
  • ✅ Ungrouped sections still save individually

Missing test coverage:

  • The group: null opt-out path (a section explicitly opting out of an inherited group)
  • Mixed scenario: one grouped + one ungrouped section with changes simultaneously
  • The isSaving button-disabled state during a batch

None of these are blockers for merging given the bug is fixed and the primary paths are covered.

Summary

The fix is correct, the architecture is clean, and the test suite addresses the regression. The main actionable issues are:

  1. The count badge in batch mode (PR description says it shows, but the code only shows it for ungrouped tabs)
  2. The slightly opaque group-resolution expression in useSaveBar.ts:27

Neither is a blocker. The rest are observations for future hardening.


…lution

- SaveBar: track isBatchSaving for the whole sequential batch so Save/Dismiss
  stay disabled between sections (closes the brief re-enable/double-click window
  the reviewer flagged), with a regression test holding one save open.
- useSaveBar: replace the double null-coalescing group resolution with an
  explicit two-branch form + comment.
- SaveBar.test: document why the ungrouped assertion is order-independent
  (auto-select makes the last-registered section active, not the first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVGxkuD4Fwa2JGVim8ZeVj
@Yeraze

Yeraze commented Jun 19, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the review — addressed the actionable items in cafe37f:

  • "Claude PR Assistant workflow" #1 (button re-enable window mid-batch): Fixed. SaveBar now tracks an isBatchSaving flag for the full duration of the sequential batch, so Save/Dismiss stay disabled between section saves — no double-click window. Added a regression test that holds one section's save open and asserts both buttons are disabled while the batch is in flight.
  • feat: traceroute highlighting and UI improvements #2 (double null-coalescing readability): Fixed. Replaced with an explicit two-branch resolution + comment in useSaveBar.ts.
  • Test assertion nit: Added a comment. Note the suggestion to assert on section a specifically would actually be incorrect here — the auto-select effect makes the last-registered changed section active (last write wins), so it's b that fires. The order-independent assertion is intentional.

Declining (with reasons) — all consistent with existing architecture, no change:

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(savebar): save all changed sections in a group with one click (#3552)

  • Read changed files
  • Review code quality and best practices
  • Check for potential bugs
  • Evaluate performance considerations
  • Assess security concerns
  • Evaluate test coverage
  • Post final review

This is a well-structured fix. The previous review's actionable items were addressed in cafe37f4. Here's what I found reviewing the updated code:

What's working well

Architecture: The <SaveBarGroup> context-propagation approach is elegant — a single provider wrapping a render region batches all enclosed sections without touching each leaf file. The useSaveBarGroup() hook in useSaveBar.ts cleanly inherits the group at registration time.

Batch-save resilience (SaveBar.tsx:54–59): Sequential saves with per-section try/catch correctly isolates failures, so one broken section doesn't abort the rest.

isBatchSaving flag (SaveBar.tsx:11, 52–63): The local isBatchSaving state solves the double-click window correctly. Both Save and Dismiss buttons are disabled for the full duration of the sequential batch, regardless of React state batching timing for individual section saves.

Group resolution (useSaveBar.ts:29–34): The two-branch resolution with comment is now clearly readable.

Issues found

1. useSaveBar.ts:73hasChanges/isSaving excluded from registration effect

}, [id, sectionName, group, registerSection, unregisterSection, stableOnSave, stableOnDismiss]); // eslint-disable-line react-hooks/exhaustive-deps

The lint suppression is intentional (and the separation into a second updateSection effect on line 77 handles updates). This is a known tradeoff pattern, but worth noting: if a section's id doesn't change but group changes after mount (e.g. conditional grouping), a new registerSection call would re-register the section with new state, potentially causing a flash. This is an edge case that doesn't affect the current usage (group is structurally constant), but the eslint suppression removes the guardrail.

2. SaveBar.tsx:42–44groupSections filter only matches by group id string

const groupSections = effectiveSection.group
  ? sectionsWithChanges.filter(s => s.group === effectiveSection.group)
  : [effectiveSection];

sectionsWithChanges only includes sections with hasChanges === true, so this is correct — unchanged grouped sections are rightly excluded from the "Save All" action. No bug here, but the naming groupSections is slightly misleading since it's actually "changed sections in the group." A comment or rename to groupSectionsWithChanges would improve clarity.

3. MeshCorePage.tsx:171–179 — nested SaveBarProvider for automations only

{view === 'automations' && (
  <SaveBarProvider>
    <SaveBarGroup id="meshcore-automation">
      ...
    </SaveBarGroup>
    <SaveBar />
  </SaveBarProvider>
)}

The MeshCoreSettingsView (line 187–193) doesn't get a SaveBarProvider, so if it ever registers SaveBar sections, they'd be orphaned (no provider) and would throw the "must be used within a SaveBarProvider" error. The previous review called this out and the author acknowledged it as pre-existing behavior — confirmed, it's not a regression introduced here.

Minor nits

  • GlobalSettingsPage.tsx:87: <SaveBarGroup id="settings"> uses the same group id "settings" as App.tsx:4921. They're under separate SaveBarProvider trees so there's no collision, but a search for "settings" group won't distinguish which is which. Worth considering whether "global-settings" vs "source-settings" would be clearer.

  • SaveBar.test.tsx:64: findByText('savebar.save_all') works because i18n isn't initialized in the test environment — the key is returned as-is. The comment at line 179–181 for the last-registered-wins order is a good addition and explains the saveA + saveB === 1 assertion correctly.

  • useSaveBar.ts:81–85 — the auto-select effect uses activeSection as a dep, meaning it runs whenever activeSection changes. When two sections both have hasChanges=true and activeSection is cleared (e.g. after dismiss), the last-registered section wins the auto-select (last effect fires). This matches the test assertion and is the intended behavior, just worth a brief comment since the ordering semantics are subtle.

Test coverage

The 5 tests (4 original + the new in-flight disabled test) cover the regression well:

  • ✅ Batch save (both sections called)
  • ✅ Batch dismiss (both sections called)
  • ✅ Buttons disabled for full batch duration (new)
  • ✅ Error resilience (continues after throw)
  • ✅ Ungrouped sections still save individually

The group: null opt-out path and mixed grouped/ungrouped scenario remain untested, but these aren't regressions and the current coverage is sufficient for the fix.

Summary

The fix correctly solves the root cause, the previous review's issues were addressed properly, and the implementation is clean. The issues I've noted above are either pre-existing (nested provider), non-regressions (mixed provider edge case), or minor readability improvements. No blockers.

--- | Branch: feat/3552-settings-save-all

@Yeraze

Yeraze commented Jun 19, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the second pass. The three remaining items are non-blocking nits (no behavior change), so I'm leaving the code as-is to avoid churn — reasoning below:

  • "Claude PR Assistant workflow" #1 (eslint-disable on the registration effect): The suppression is the pre-existing pattern in useSaveBar; I only added group to the dependency array, so a group change does correctly re-register. As you note, group is structurally constant in every current usage (it comes from a static <SaveBarGroup id="…">), so the re-register/flash path can't trigger today. Happy to revisit if conditional grouping is ever introduced.
  • feat: traceroute highlighting and UI improvements #2 (groupSections naming): Agreed the name is shorthand — the comment immediately above it already documents that it's "every changed section in that group" (the filter runs over sectionsWithChanges). Leaving the name to keep the diff minimal.
  • "Update Claude PR Assistant workflow" #3 (nested SaveBarProvider in MeshCorePage): Pre-existing structure — the automations view already had its own provider before this PR; out of scope here.

CI is green across the matrix. Ready for merge from my side.

@Yeraze Yeraze merged commit 38f36f7 into main Jun 19, 2026
20 checks passed
@Yeraze Yeraze deleted the feat/3552-settings-save-all branch June 19, 2026 15:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Settings only partially save when multiple sections edited before clicking Save

1 participant