Skip to content

fix(builder): rebase draft fields after save to stop duplicate POSTs - #56

Merged
Morialkar merged 2 commits into
mainfrom
claude/cool-blackburn-6e01b5
Aug 1, 2026
Merged

Morialkar merged 2 commits into
mainfrom
claude/cool-blackburn-6e01b5

Conversation

@Morialkar

Copy link
Copy Markdown
Owner

Problem

In apps/client/src/routes/builder.$databaseId.$tableId.tsx, handleSaveAll splits fields into new ones (POST /fields) and existing ones (PUT /fields/:id), but after a successful save it only called queryClient.invalidateQueries(['fields', tableId]). It never updated local state, and the load effect is guarded by initializedRef.current, so fields is never re-synced from the server.

Consequence: a field created in this session keeps isNew: true and persistedId: null locally. Clicking Save a second time POSTs it again, creating a duplicate field on the table.

Change

handleSaveAll now captures each mutation response keyed by the draft field id and rebases local state from it — isNew: false, persistedId from the created field's id — so a second Save takes the PUT branch.

Two details beyond the minimum fix:

  • Saves settle per field rather than through a bare Promise.all. With all-or-nothing rejection, one failing field would discard the rebase for every field that did save, reintroducing the duplicate on the next click.
  • Failed saves keep their draft state (persistedId stays null → still isNew), so nothing is flipped to a PUT against a null id.

The setFields update is functional and only touches isNew/persistedId, so edits made while a save is in flight survive the rebase.

Test

New apps/client/src/routes/__tests__/builder.$databaseId.$tableId.test.tsx: adds a field to an empty table, clicks Save twice, and asserts exactly one POST /fields plus one PUT /fields/field-created-1.

Verified the test fails with the fix reverted (the second click issues a second POST and never a PUT), then passes with it restored.

Checks

  • Client suite: 17 files, 61 tests passing
  • tsc -b clean, Prettier and oxlint clean (the one oxlint warning is pre-existing in navigation.$databaseId.tsx)

Reviewer note

initializedRef still permanently blocks re-syncing fields from the server, so the post-save invalidateQueries refetch only serves other consumers of that query. That's fine now that state is rebased locally, but it does mean the builder never picks up field changes made elsewhere after mount — a separate concern, not addressed here.

🤖 Generated with Claude Code

handleSaveAll only invalidated the fields query after saving, and the load
effect is guarded by initializedRef, so local state was never re-synced. A
field created in this session kept isNew: true / persistedId: null, and a
second click on Save POSTed it again, creating a duplicate field on the table.

Capture each mutation response keyed by draft id and rebase local state from
it (isNew: false, persistedId from the created field), so the second save
takes the PUT branch. Saves are settled per field rather than through a bare
Promise.all: an all-or-nothing rejection would have discarded the rebase for
every field that did save, reintroducing the duplicate on the next click.
Fields whose save failed keep their draft state.

Adds a regression test asserting two consecutive saves issue exactly one POST
per new field, followed by a PUT to the created id.
@Morialkar
Morialkar force-pushed the claude/cool-blackburn-6e01b5 branch from 906b7a2 to 87d3651 Compare July 31, 2026 23:59
Conflict in handleSaveAll: #55 added a savedSnapshot rebase for the
unsaved-changes guard in the same .then() this branch rewrote.

Resolved to keep both. This branch settles each save individually instead of
using a bare Promise.all, so .then() now runs even when a field fails — main's
`setSavedSnapshot(snapshotFields(fields))` would have marked failed fields
clean and silently disarmed the guard. The snapshot is therefore merged for
the fields that actually round-tripped, leaving failed ones dirty.

Also adds useBlocker to this branch's router mock, and covers the combined
behaviour: dirty clears after a successful save, and a field whose save failed
stays new and unsaved (retried as a create, not a duplicate update).
@Morialkar
Morialkar merged commit 921da32 into main Aug 1, 2026
5 checks passed
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.

1 participant