Unchanged default-language save spells out defaults and rewrites the connected translation ("2 changes") #219

Closed
opened 2026-10-12 09:25:39 +02:00 by dominik.polakovics · 0 comments

This was generated by AI during triage.

Summary

Saving an element in the default language without changing anything rewrites its connected German translation, so the pending-changes chip shows "2 changes" for one no-op save. A save that changes nothing must leave both files alone.

Current behavior

The form describe seeds a param the content file lacks with its sidecar default (Schema.describe in internal/element/form.go, via Param.Empty). The form snapshot sends every field back on save (ElementForm, form.snapshot()), and elemops.MergeParams writes every managed value it receives. So an element whose file omits an Appearance param with a default — e.g. the hero's align (default: left, tab: appearance) — gains align="left" on an "unchanged" save: change number one. Because the save is in the default language, Handler.syncTranslations (internal/server/translation.go) runs l10n.SyncAppearance for every connected translation; the German element lacks align, the source now has it, so the German file is rewritten with align="left": change number two.

Confirmed reproduction (Go, copyFixture(t, multilingualSiteDir) with languages en/de): POST /api/page/save {page:"services/webdesign/_index.md", lang:"en", path:[0], name:"hero", values:{title:"Web design", subtitle:"Sites that fit.", align:"left"}} — the values the form holds for that element. Before: neither _index.md nor _index.de.md has align. After: both files carry align="left"; the German file was rewritten. Sending the same values without align leaves both files byte-identical.

Content hits this whenever a file omits an Appearance param that the sidecar defaults: hand-authored or migrated pages, translations created from such sources, and any element inserted before its sidecar gained the param. Elements inserted by the editor write their defaults at insert (elemops.NewShortcode), so fresh editor-made content does not show it until a sidecar changes.

Desired behavior

  • Saving an element whose form values equal what the file holds — including fields the form seeded from defaults — writes nothing: the default-language file and every connected translation stay byte-identical, and the pending list shows no new row.
  • Saving a real change to a translatable (General-tab) field touches only the default-language file.
  • Saving a real change to an Appearance (synced) field still propagates that value into every connected translation, as documented.
  • Setting an Appearance field that the file already holds explicitly back to its default keeps it explicit (no churn either way).

Decisions

  • A save writes a managed param when the file already holds it, or when the submitted value differs from the sidecar default; it does not add a param only to spell out its default. Reason: this keeps editor-inserted elements (which spell defaults out at insert) stable, keeps hand-authored files stable, and makes an unchanged save a no-op without special-casing the sync. The alternative — propagating only values this save changed — would weaken the documented invariant that synced params "always hold the source element's value" (AGENTS.md "Translations"), so it was not chosen.
  • Propagation stays as it is: a connected translation is written only when SyncAppearance or Repair actually changed it (already true today); the fix is that an unchanged save no longer produces a change to propagate.

Key interfaces

  • elemops.MergeParams(existing, schema, clean) in internal/elemops/save.go and Param.ShortcodeValue / Param.Initial / Param.Default in internal/element — where "write this param or not" is decided. Items rows (buildItemShortcodes) take the same decision for their fields.
  • Schema.describe in internal/element/form.go — how a missing param's form value is seeded; the fix must not change what the form shows.
  • Handler.syncTranslations and l10n.SyncAppearance (internal/server/translation.go, internal/l10n/track.go) — unchanged in principle; add a regression test that an unchanged default-language save leaves the connected translation byte-identical, next to TestSourceOpsPropagateIntoConnectedTranslations in internal/server/translation_ops_test.go.
  • l10n.Hash / canonicalParam (internal/l10n/hash.go) — must treat an absent param and an explicit default as the same value, or the "source changed since this translation was saved" note would flip when a default is spelled out or dropped.

Acceptance criteria

  • Server test: saving the multilingual fixture's hero in en with the values the form holds (including align: "left") leaves _index.md and _index.de.md byte-identical.
  • Server test: saving align: "center" in en writes it to both the source and the connected de file; a free translation is untouched.
  • Server test: saving a changed title in en leaves the connected de file byte-identical.
  • Server test: a file that holds align="left" explicitly keeps it after an unchanged save (no churn in the other direction); an explicit non-default set back to the default stays explicit.
  • Items rows follow the same rule for their fields.
  • The l10n change hash is identical for an element with and without a spelled-out default.
  • In the editor, with a page whose files lack a defaulted Appearance param: open an element, press Save without edits, the pending chip count does not change.
  • scaffold/site/AGENTS.md describes which params a save writes (content canonicalization / element params) truthfully after the change; edit-content and migrate-site skills revisited for any statement about defaults in files.

How to verify

  • go test ./... with the new tests in internal/server and internal/elemops; cd web && npm test.
  • harness check on testdata/multilingual-site and the scaffold site still passes.
  • Manually as in the editor criterion above, then change an Appearance field and confirm the German file updates.

Assumptions to check

  • That Param.Default is normalized to the same Go type the form submits and the content parser yields (e.g. numbers), so "equals the default" compares correctly for every field type that can carry a default (select, bool, number, text, datetime, link with companions).
  • That nothing relies on the save spelling defaults out — e.g. the hash in internal/l10n/hash.go, the pending-list element diff (diffElements in internal/server/pending.go), or theme templates reading a param without a fallback (the theme must already cope, since hand-authored content omits params).
  • Whether the editor's page-settings save (/api/page/settings/save) has the same default-spelling behavior for front matter; note it if so, but see Out of scope.

Out of scope

  • Changing what elemops.NewShortcode writes at insert.
  • The propagation rules themselves (what is synced vs translatable), or the l10nSource stamping of translations.
  • Page properties / front-matter saves, unless the same helper is shared.

Blocked by

None - can start immediately

> *This was generated by AI during triage.* ## Summary Saving an element in the default language without changing anything rewrites its connected German translation, so the pending-changes chip shows "2 changes" for one no-op save. A save that changes nothing must leave both files alone. ## Current behavior The form describe seeds a param the content file lacks with its sidecar default (`Schema.describe` in `internal/element/form.go`, via `Param.Empty`). The form snapshot sends every field back on save (`ElementForm`, `form.snapshot()`), and `elemops.MergeParams` writes every managed value it receives. So an element whose file omits an Appearance param with a default — e.g. the hero's `align` (`default: left`, `tab: appearance`) — gains `align="left"` on an "unchanged" save: change number one. Because the save is in the default language, `Handler.syncTranslations` (`internal/server/translation.go`) runs `l10n.SyncAppearance` for every connected translation; the German element lacks `align`, the source now has it, so the German file is rewritten with `align="left"`: change number two. Confirmed reproduction (Go, `copyFixture(t, multilingualSiteDir)` with languages en/de): `POST /api/page/save {page:"services/webdesign/_index.md", lang:"en", path:[0], name:"hero", values:{title:"Web design", subtitle:"Sites that fit.", align:"left"}}` — the values the form holds for that element. Before: neither `_index.md` nor `_index.de.md` has `align`. After: both files carry `align="left"`; the German file was rewritten. Sending the same values without `align` leaves both files byte-identical. Content hits this whenever a file omits an Appearance param that the sidecar defaults: hand-authored or migrated pages, translations created from such sources, and any element inserted before its sidecar gained the param. Elements inserted by the editor write their defaults at insert (`elemops.NewShortcode`), so fresh editor-made content does not show it until a sidecar changes. ## Desired behavior - Saving an element whose form values equal what the file holds — including fields the form seeded from defaults — writes nothing: the default-language file and every connected translation stay byte-identical, and the pending list shows no new row. - Saving a real change to a translatable (General-tab) field touches only the default-language file. - Saving a real change to an Appearance (synced) field still propagates that value into every connected translation, as documented. - Setting an Appearance field that the file already holds explicitly back to its default keeps it explicit (no churn either way). ## Decisions - A save writes a managed param when the file already holds it, or when the submitted value differs from the sidecar default; it does not *add* a param only to spell out its default. Reason: this keeps editor-inserted elements (which spell defaults out at insert) stable, keeps hand-authored files stable, and makes an unchanged save a no-op without special-casing the sync. The alternative — propagating only values this save changed — would weaken the documented invariant that synced params "always hold the source element's value" (AGENTS.md "Translations"), so it was not chosen. - Propagation stays as it is: a connected translation is written only when `SyncAppearance` or `Repair` actually changed it (already true today); the fix is that an unchanged save no longer produces a change to propagate. ## Key interfaces - `elemops.MergeParams(existing, schema, clean)` in `internal/elemops/save.go` and `Param.ShortcodeValue` / `Param.Initial` / `Param.Default` in `internal/element` — where "write this param or not" is decided. Items rows (`buildItemShortcodes`) take the same decision for their fields. - `Schema.describe` in `internal/element/form.go` — how a missing param's form value is seeded; the fix must not change what the form shows. - `Handler.syncTranslations` and `l10n.SyncAppearance` (`internal/server/translation.go`, `internal/l10n/track.go`) — unchanged in principle; add a regression test that an unchanged default-language save leaves the connected translation byte-identical, next to `TestSourceOpsPropagateIntoConnectedTranslations` in `internal/server/translation_ops_test.go`. - `l10n.Hash` / `canonicalParam` (`internal/l10n/hash.go`) — must treat an absent param and an explicit default as the same value, or the "source changed since this translation was saved" note would flip when a default is spelled out or dropped. ## Acceptance criteria - [ ] Server test: saving the multilingual fixture's hero in `en` with the values the form holds (including `align: "left"`) leaves `_index.md` and `_index.de.md` byte-identical. - [ ] Server test: saving `align: "center"` in `en` writes it to both the source and the connected `de` file; a free translation is untouched. - [ ] Server test: saving a changed `title` in `en` leaves the connected `de` file byte-identical. - [ ] Server test: a file that holds `align="left"` explicitly keeps it after an unchanged save (no churn in the other direction); an explicit non-default set back to the default stays explicit. - [ ] Items rows follow the same rule for their fields. - [ ] The l10n change hash is identical for an element with and without a spelled-out default. - [ ] In the editor, with a page whose files lack a defaulted Appearance param: open an element, press Save without edits, the pending chip count does not change. - [ ] `scaffold/site/AGENTS.md` describes which params a save writes (content canonicalization / element params) truthfully after the change; `edit-content` and `migrate-site` skills revisited for any statement about defaults in files. ## How to verify - `go test ./...` with the new tests in `internal/server` and `internal/elemops`; `cd web && npm test`. - `harness check` on `testdata/multilingual-site` and the scaffold site still passes. - Manually as in the editor criterion above, then change an Appearance field and confirm the German file updates. ## Assumptions to check - That `Param.Default` is normalized to the same Go type the form submits and the content parser yields (e.g. numbers), so "equals the default" compares correctly for every field type that can carry a default (select, bool, number, text, datetime, link with companions). - That nothing relies on the save spelling defaults out — e.g. the hash in `internal/l10n/hash.go`, the pending-list element diff (`diffElements` in `internal/server/pending.go`), or theme templates reading a param without a fallback (the theme must already cope, since hand-authored content omits params). - Whether the editor's page-settings save (`/api/page/settings/save`) has the same default-spelling behavior for front matter; note it if so, but see Out of scope. ## Out of scope - Changing what `elemops.NewShortcode` writes at insert. - The propagation rules themselves (what is synced vs translatable), or the `l10nSource` stamping of translations. - Page properties / front-matter saves, unless the same helper is shared. ## Blocked by None - can start immediately
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#219
No description provided.