fix(sections-editor): keep stored values on real conflicts and follow moved sections
What is this contribution about?
Follow-up to #7909. That PR stopped a stale section form from silently reverting changes published from another draft: when the stored section moved on, the editor merges the form's edits on top of it. The merge it uses in the editor still has three cases where the result surprises the user. This PR fixes them, plus one place that still failed outright.
What was still wrong
- A value both sides changed: the form won silently. If someone else changed the headline and I changed it too, mine was saved. The toast said my changes were applied on top of the latest version, which read as if nothing was lost, but the other value was gone.
- A section inserted above the open one: two sections got spliced together. Inline page sections are addressed by position. After someone inserted, removed, or moved a section above mine, the form's position pointed at a different section. The merge then applied my edits to it, mixing the two. Before #7909, the form overwrote that section outright.
- A section whose type changed was merged anyway. This produced the new type with the old type's props.
- Saves outside the section form failed. Page name, path, SEO, and matcher edits use the cached block as their base. When the server refused one as stale, the user got "not saved" and the edit was lost, even when it touched a value nobody else had changed.
The fix
- Merge rules (
mergeBlockJsonin@decocms/shared/decofile): the merge now takes the side to prefer and reports the JSON pointer of every value both sides changed. The draft sync keeps preferring the draft, so its behaviour is unchanged. - Section form (
sections-editor.tsx,form-base.ts):- Edits to different values merge silently, as before.
- A value both changed keeps the stored version. The rest of the edit is saved, and a warning names the fields that kept the stored value ("Changed while you were editing: headline. The latest version of those fields was kept, and the rest of your changes were saved."). The form shows the merged values.
- If the page gained or lost sections and the section the form opened is found, unchanged, at exactly one other position, the form follows it there and saves at the new position. If the page has as many sections as before, the section at the form's position is treated as the form's own, edited in place; a page can repeat a section verbatim, and following the twin would write to the wrong one. If the count changed and the section can't be told apart, the form reloads.
- If a section of a different type now sits at the form's position, the form reloads instead of merging, with a toast saying the last change was not saved.
- Other saves (
use-save-block.ts): a save built from the cache that the server refuses is replayed once on the fresh copy, when the two edits touched different values. On a real conflict it still fails, and the message now names the fields. The section form opts out withrebaseOnConflict: falsebecause it resolves conflicts itself.
Why prefer the stored value in the editor
This matches what the previous admin did within one environment: its JSON Patch test operations refused a change to a value someone else had changed. The difference is that the previous admin then discarded the whole edit; here only the conflicting fields keep the stored value, and the rest is saved. Between drafts, the draft still wins per value (unchanged from #7909). A warning at publish time about what will be overwritten is left for a separate PR, because it needs UI in the publish dialog.
How did you verify your code works?
bun run verify(fmt, oxlint, knip, 10,933 unit tests) andbun run checkpass.- New unit tests:
block-merge.test.ts: no conflicts reported for disjoint edits, pointer paths (including escaping),prefer: "theirs"keeping the stored value and the rest of the edit, whole-array conflict.form-base.test.ts:sectionTypeChanged,findSectionMovedTo.conflict-fields.test.ts: readable field names, 1-based positions, truncation.
- New browser tests in
site-editor-stale-form.spec.ts, run locally against the GitHub stub:- Same value changed by both: the stored headline stays, the form shows it, and the warning names "headline".
- Section inserted above the open one: the edit lands on the section the form opened, at its new position, and the inserted section is untouched.
- Identical twin sections, with someone else editing the open one in place: the edit stays on the open one and merges with theirs. A first version of this PR followed the untouched twin instead; this test reproduces that in the browser and now passes.
- Renaming a page whose sections someone else changed: the first save gets a 409 and is replayed on the fresh page (asserted
[409, 200]), and both changes are stored with no error toast.
- The first three fail on
main's editor code and pass with this change. - Related specs (
site-editor-stale-form,decofile-api,fast-preview-git-sync,site-editor-breadcrumbs,cms-publish-surface): 33 passed locally. - Not run: the full e2e suite (CI will run it).
Screenshots/Demonstration
A value someone else also changed keeps the stored version, and the warning names it:

How to Test
- Open a Fast Preview project in two browsers, A and B, on the same draft, and open the same section in both.
- In B, change the headline. In A, change the headline too.
- Expected: A's form shows B's headline and a warning naming "headline".
- In B, insert a section above the one A has open. In A, edit a field.
- Expected: the edit is saved on A's section, now one position down, and the inserted section is unchanged.
- In B, edit a section of a page. In A, rename that page.
- Expected: both changes are saved and A sees no error.
Migration Notes
None.
Review Checklist
- PR title is clear and descriptive
- Changes are tested and working
- Documentation is updated (if needed)
- No breaking changes