Skip to content

fix(workflow,logic): auto-save on card switch discards edits - #9849

Merged
kevin9foong merged 3 commits into
developfrom
fix/autosave-dirty-subscription
Aug 14, 2026
Merged

fix(workflow,logic): auto-save on card switch discards edits#9849
kevin9foong merged 3 commits into
developfrom
fix/autosave-dirty-subscription

Conversation

@bookwormsuf

@bookwormsuf bookwormsuf commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Problem

Editing a step or logic block that already exists, then clicking another card, silently discards the edits. No error is shown. Found while rebasing an approval-reframe branch onto the click-to-edit work.

Not behind a flag: editable-cards-mrf-logic was dropped in 8caec073a, and InactiveStepBlock's onClick is unconditional. Limited so far only by recency — #9781 and #9786 merged today.

Cause

The auto-save effect decides whether to save by reading formState.isDirty. RHF's formState is a Proxy that only starts tracking a flag once something reads it during render — that read is the subscription. Both components read isDirty only inside the effect, which runs after render, so nothing ever subscribed and the value stayed at its initial false. The effect took the "nothing changed" branch and switched without saving.

New blocks were unaffected because isCreatingState short-circuits before reaching isDirty. That's why this went unnoticed: the natural thing to test is creating a step and clicking away, and that path works.

Verified empirically with a throwaway harness reproducing the component shape (parent useForm, children destructuring formState: { errors }, a Controller-driven edit, an effect keyed on a pending-switch prop):

Render-time subscription isDirty seen by effect Edit landed?
No (before) false value === 'edited'
Yes (after) true value === 'edited'

The right-hand column is the control, proving the edit registered in both arms.

Solution

Read isDirty during render so RHF subscribes, and use that variable in the effect. Two lines, one per component.

Dependency array stays [pendingSwitchTo]: the edit re-renders once subscribed, and the card click re-renders again, so the effect always closes over a current value. Adding isDirty to the deps would re-run the effect mid-switch on every keystroke, leaning entirely on hasSubmittedForPendingSwitch to prevent double submits.

Tests

  • Save a step so it exists → reopen it → change the step name → click another card → reopen: the change is still there
  • Same for a logic block
  • Control: brand-new unsaved step, edit, click another card → still saves (this already worked)
  • Untouched existing step → click another card → switches with no save request (the short-circuit still short-circuits)
  • fix(workflow,logic): guard auto-save effect against double submit #9838 regression: rapid double-click from a dirty new step → exactly one create request

Notes

  • No automated test. The bug is a render-time subscription with no seam to unit-test. A real component test via composeStories hits the workflow_type double-registration quirk the stories file already documents, and grew several times larger than the two-line fix. Left as the manual TCs above.
  • Relation to fix(workflow,logic): guard auto-save effect against double submit #9838. That PR fixed a real double-submit race on the new-block path and is untouched here. The two are complementary: it stops a duplicate save on new blocks, this makes existing blocks save at all.

🤖 Generated with Claude Code

Editing a step or logic block that already exists, then clicking another
card, silently discarded the edits. No error was shown.

The auto-save effect decides whether to save by reading formState.isDirty.
RHF's formState is a Proxy that only starts tracking a flag once something
reads it during render — that read is the subscription. Both components
read isDirty only inside the effect, which runs after render, so nothing
ever subscribed and the value stayed at its initial false. The effect then
took the "nothing changed" branch and switched without saving.

New blocks were unaffected because isCreatingState short-circuits the
check before it reaches isDirty, which is why this went unnoticed: the
natural thing to test is creating a step and clicking away, and that works.

Reading isDirty during render subscribes properly, so it reflects real
edits by the time the effect runs. The dependency array stays
[pendingSwitchTo]: the edit re-renders once subscribed, and the card click
re-renders again, so the effect always closes over a current value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bookwormsuf
bookwormsuf requested review from a team and a lite review from Copilot August 13, 2026 15:02

Copilot AI 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.

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Fixes a React Hook Form (RHF) subscription issue where edits to existing step/logic blocks were lost when switching cards because formState.isDirty was only read inside an effect (post-render), so RHF never subscribed to updates.

Changes:

  • Subscribe to formState.isDirty during render in step and logic editors.
  • Use the render-subscribed isDirty value in the auto-save-on-switch branch decision.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
apps/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/EditStepBlock.tsx Reads isDirty during render and uses it to decide whether to auto-save on card switch.
apps/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditLogicBlock.tsx Mirrors the same render-time isDirty subscription fix for logic blocks.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

// Read during render, not inside the effect below: RHF's formState is a Proxy
// that only tracks a flag once something reads it while rendering. Read only
// from the effect it stays false, and edits were discarded on switch.
const { isDirty } = formMethods.formState
// existing step that wasn't touched can switch directly without a
// redundant save.
if (!isCreatingState && !formMethods.formState.isDirty) {
if (!isCreatingState && !isDirty) {
// Read during render, not inside the effect below: RHF's formState is a Proxy
// that only tracks a flag once something reads it while rendering. Read only
// from the effect it stays false, and edits were discarded on switch.
const { isDirty } = formMethods.formState
// An existing block that wasn't touched can switch directly without a
// redundant save.
if (!isCreatingState && !formMethods.formState.isDirty) {
if (!isCreatingState && !isDirty) {

// Read during render, not inside the effect below: RHF's formState is a Proxy
// that only tracks a flag once something reads it while rendering. Read only
// from the effect it stays false, and edits were discarded on switch.
The old phrasing garden-paths on "Read only from the effect". Same
explanation, split so the conditional clause reads as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bookwormsuf

Copy link
Copy Markdown
Contributor Author

Re the Copilot review: its own summary notes the agentic review timed out, so it only saw the diff hunks.

The four react-hooks/exhaustive-deps comments don't apply. Each hedges with "not shown in this hunk" — the effect in question already carries a disable, and it predates this PR (it's on develop):

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pendingSwitchTo])

Lint on both changed files is clean: 0 errors, 1 pre-existing react-refresh/only-export-components warning on a constant export unrelated to this diff.

Worth noting the disable is load-bearing rather than incidental: the effect fires only on pendingSwitchTo by design, so adding isDirty to the array would re-run it on every keystroke.

The grammar comment was fair — "Read only from the effect" garden-paths. Reworded in 6402e6c.

@kevin9foong
kevin9foong merged commit dea502f into develop Aug 14, 2026
9 of 11 checks passed
@kevin9foong
kevin9foong deleted the fix/autosave-dirty-subscription branch August 14, 2026 01:19
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.

3 participants