Compare commits

...
Author SHA1 Message Date
Andrey Antukh f1bf63101c 🚧 Add plan file (REMOVE THIS COMMIT BEFORE MERGE) 2026-08-26 19:20:17 +02:00
Andrey Antukh fb7497b4b7 🐛 Sync grid cells when converting a component frame to grid
Converting a component's main frame to a grid only propagated the
:layout and track attributes to its copies: update-grid-copy-attrs
was gated on the copy shape (still a plain frame at sync time) and,
once running, merge-cells kept the copy's empty cells instead of
taking the main's. Copies ended up with no cells and a later reflow
snapped their children back to the old order.

Gate the grid sync on the main shape (like the flex branch) and take
the main's remapped cells wholesale when the copy has not touched its
:layout-grid-cells group.

AI-assisted-by: deepseek-v4-pro
2026-08-26 17:09:17 +00:00
3 changed files with 378 additions and 2 deletions

No files matched your search

@@ -0,0 +1,214 @@
# Plan: Fix grid cells not synced when converting a component frame to grid/flex
## Context
When a component's main frame `A` (children `B C D`) is converted to a layout
(grid or flex), the editor reorders `A`'s children (e.g. to `D C B`). Those
changes propagate to a copy `A'`, whose children become `D' C' B'`. A second
relayout then fires on `A'` and reorders its children back to `B' C' D'` — that
last jump is wrong: the copy's children are already correct and the relayout
should not fire.
Root cause is in the component sync engine (`common/`), not the frontend
reflow. Two defects combine:
1. **Gating asymmetry.** In `generate-sync-shape-direct-recursive`
(`common/src/app/common/logic/libraries.cljc`), the flex branch is gated on
the *main* shape (`(ctsl/flex-layout? shape-main)`, line 888) but the grid
branch is gated on the *copy* shape (`(ctsl/grid-layout? shape-inst)`,
line 1003). When a frame is converted to grid, the copy `shape-inst` is still
a plain frame at sync time, so `update-grid-copy-attrs` never runs and the
copy ends up with `:layout :grid` + tracks but **empty `:layout-grid-cells`**.
2. **`merge-cells` does not propagate structure.** Even when it runs,
`update-grid-copy-attrs` (line 2439) merges the main's cells with
`ctsl/merge-cells` (`common/src/app/common/types/shape/layout.cljc` line
1694). With `omit-touched?=true` it keeps the copy's cell positions
(`:row`/`:column`/`:row-span`/`:column-span`) and the copy's cell set (it
only patches `:shapes`/`:position`/`:area-name` for cells sharing a UUID;
new/removed cells do not propagate). For a freshly converted frame the copy
has no cells, so the result is empty and the cells are re-derived from stale
positions — producing an order different from the main.
The consequence is that the copy's `:layout-grid-cells` never reflect the main's
cell assignment. The frontend `sync-file-frontend-events`
(`frontend/src/app/main/data/workspace/libraries.cljs` line 1152) then emits
`:layout/update` for the copy, the reflow repositions the copy's children from
stale/empty cells, and the visual order snaps back — the spurious relayout.
## Affected Modules
- `common/` — sync engine and grid layout data model. See `mem:common/core`,
`mem:common/component-data-model`, `mem:common/layout-grid-subtleties`,
`mem:common/changes-architecture`, `mem:common/testing`.
- `frontend/` (context only) — `sync-file-frontend-events` emitting
`:layout/update`; no change expected unless the fix reveals a frontend
assumption. See `mem:frontend/core`.
Key invariant from `mem:common/layout-grid-subtleties`: copy child order is
owned by the component sync engine; `:reorder-children`/`:mov-objects` refuse to
alter copies unless `allow-altering-copies`. The grid cell sync must therefore
make the copy's cells consistent with the copy's (already-synced) `:shapes`
order.
## Architecture Decisions
- **Propagate the main's cells wholesale when the copy has no grid override.**
The copy's grid override is already tracked per-shape by the `:layout-grid-cells`
touched group (`ctk/touched-group?`). When that group is not touched, the
correct behavior is to copy the main's `:layout-grid-cells` after remapping
shape ids (`ctsl/remap-grid-cells`), preserving cell ids/positions/assignments.
Only when the group is touched do we preserve the copy's cells (current merge
behavior).
- **Align the grid gating with the flex gating** by checking `shape-main`
instead of `shape-inst`. This is the minimal, symmetric fix and matches the
flex branch.
- **Keep the fix in `common/`.** No change to the frontend reflow path is
needed; the reflow is correct once the copy's cells are correct.
## Risks & Considerations
- **Touched overrides must be preserved.** A user may manually move a copy child
to a different cell (`:layout-grid-cells` touched). The fix must keep the
existing merge path for that case, otherwise user overrides in copies would be
wiped on every sync.
- **Reset sync (`omit-touched?=false`).** Today `merge-cells` returns the copy's
cells unchanged for reset (no merge), which is also wrong for reset-to-main.
The wholesale branch improves reset too (touched is cleared before the cell
sync, so the group is not touched and main's cells are taken).
- **Remote/nested components.** `update-grid-copy-attrs` remaps via
`shape-ref`/swap-slot lookup; the wholesale path must reuse the same `ids-map`
so nested instances and swap slots keep working.
- **Cell id identity.** Cell ids are `uuid/next`; main and copy cell ids only
match if the copy was instantiated from a grid main. Comparing by cell id is
not reliable; the fix must compare/assign by the remapped shape ids and
positions, not by cell uuid equality.
## Approach
Follow TDD (Red → Green → Refactor) with the Prove-It pattern: first write a
reproduction test that fails, then fix, then confirm green.
### Step 1 — RED: reproduction test (this step)
Add a test to `common/test/common_tests/logic/comp_sync_test.cljc` that:
1. Builds a component with a frame main + three rect children at distinct `x`
positions, plus a copy (using `tho/add-component-with-many-children-and-copy`).
2. Converts the main frame to a grid via the production-path update helper
`cls/generate-update-shapes` with `:with-objects? true`, applying the same
transformation as the frontend `get-layout-initializer`:
merge grid layout attrs + `grid/calculate-params` + `ctl/assign-cells` +
`ctl/reorder-grid-children`.
3. Runs `cll/generate-sync-file-changes` and `thf/apply-changes`.
4. Asserts that the copy is a grid, that its `:layout-grid-cells` has the same
cell structure as the main's (remapped to copy ids), and that its `:shapes`
order matches the main's (mapped through `:shape-ref`).
The test fails today because the copy's `:layout-grid-cells` is empty.
### Step 2 — GREEN: fix the gating
In `common/src/app/common/logic/libraries.cljc`, change line 1003 from
`(ctsl/grid-layout? shape-inst)` to `(ctsl/grid-layout? shape-main)`.
### Step 3 — GREEN: propagate main cells when the copy is not touched
In `update-grid-copy-attrs` (`common/src/app/common/logic/libraries.cljc` line
2439), branch on `(ctk/touched-group? shape-copy :layout-grid-cells)`:
- not touched → `(assoc :layout-grid-cells (ctsl/remap-grid-cells shape-main ids-map) :layout-grid-cells)`
(take the main's cells wholesale, remapped), then `(ctsl/assign-cells objects)`.
- touched → keep the current `merge-cells` behavior.
This also fixes the reset path.
### Step 4 — GREEN: flex coverage (verification)
Confirm `update-flex-child-copy-attrs` (line 2378) already propagates child flex
attrs on frame→flex conversion (its gating already uses `shape-main`). Add a
sibling test if it is missing coverage; no code change expected unless the test
surfaces a symmetric defect.
### Step 5 — REFACTOR + full verification
Run the full common suite and lint/format checks (see Testing Strategy).
## Task List
### Phase 1: Reproduction (RED)
- [ ] Task 1: Add `test-sync-grid-cells-when-converting-main-to-grid` to
`common/test/common_tests/logic/comp_sync_test.cljc`.
**Acceptance criteria:**
- [ ] Test builds a component+copy, converts main to grid, syncs, and asserts the
copy's `:layout-grid-cells` (remapped) and `:shapes` order match the main.
- [ ] Test FAILS when run (copy's cells empty / mismatch).
**Verification:**
- [ ] `clojure -M:dev:test --focus common-tests.logic.comp-sync-test/test-sync-grid-cells-when-converting-main-to-grid` → RED (output to file, then read).
**Dependencies:** None
### Checkpoint: Phase 1
- [ ] Reproduction test is RED, proving the bug.
### Phase 2: Fix (GREEN)
- [ ] Task 2: Fix grid gating `shape-inst``shape-main` (libraries.cljc:1003).
- [ ] Task 3: `update-grid-copy-attrs` takes main's cells wholesale when the copy's
`:layout-grid-cells` group is not touched.
**Acceptance criteria:**
- [ ] Copy's `:layout-grid-cells` matches the main's (remapped) after sync.
- [ ] Copy with `:layout-grid-cells` touched preserves its override.
**Verification:**
- [ ] Focused test goes GREEN.
- [ ] Existing `comp_sync_test` still passes.
### Checkpoint: Phase 2
- [ ] Reproduction test green + no regressions in `comp_sync_test`.
### Phase 3: Flex verification + polish
- [ ] Task 4: Verify flex conversion path; add coverage if missing.
- [ ] Task 5: Full common suite + lint/format.
**Verification:**
- [ ] `clojure -M:dev:test` (full JVM common suite) green.
- [ ] `pnpm run lint:clj` / `check-fmt:clj` clean (from `common/` or repo root as applicable).
### Checkpoint: Complete
- [ ] All acceptance criteria met.
- [ ] Ready for review.
## Testing Strategy
- JVM (fast, canonical for component/sync logic):
`clojure -M:dev:test --focus common-tests.logic.comp-sync-test` (or a single var).
- Full common suite: `clojure -M:dev:test`.
- JS coverage (CLJC): `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test`.
- Always redirect test output to a file first, then read it (never pipe to
`head`/`tail`/`grep`). See `mem:testing`.
- Lint/format: `pnpm run lint:clj` and `pnpm run check-fmt:clj`.
Cover these cases:
- frame→grid conversion: copy gets cells matching main.
- main grid track reorder: copy cells follow (positions propagate).
- copy with `:layout-grid-cells` touched: override preserved.
- frame→flex conversion: child flex attrs propagate (regression guard).
## Parallelization Opportunities
- **Safe to parallelize:** flex coverage test (Task 4) is independent of the grid
fix once the reproduction test exists.
- **Must be sequential:** Tasks 1 → 2 → 3 (RED before GREEN).
- **Needs coordination:** none (single module `common/`).
## Open Questions
- Whether `:shapes-group` (structural child reorder) should also be consulted in
`update-grid-copy-attrs` beyond `:layout-grid-cells` — to be decided during
implementation if the copy's `:shapes` order and cells can still diverge when
only `:shapes-group` is touched.
+8 -2
View File
@@ -1000,7 +1000,7 @@
changes
(cond-> changes
(ctsl/grid-layout? shape-inst)
(ctsl/grid-layout? shape-main)
(update-grid-copy-attrs
(:id shape-inst)
shape-main
@@ -2477,9 +2477,15 @@
;; Take cells from main and remap the shapes to assign it to the copy
copy-cells (-> shape-copy :layout-grid-cells (remove-orphan-cells shape-copy))
main-cells (-> shape-main (ctsl/remap-grid-cells ids-map) :layout-grid-cells)]
;; When the copy hasn't overridden its grid cells, take the main's
;; cells wholesale (positions + assignments + new/removed cells).
;; Otherwise preserve the copy's overrides cell by cell.
(-> shape-copy
(assoc :layout-grid-cells
(ctsl/merge-cells main-cells copy-cells omit-touched?))
(if (ctk/touched-group? shape-copy :layout-grid-cells)
(ctsl/merge-cells main-cells copy-cells omit-touched?)
main-cells))
(ctsl/assign-cells objects))))
{:ignore-touched true :with-objects? true})))
@@ -8,8 +8,11 @@
(:require
[app.common.data :as d]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.common.geom.shapes.flex-layout :as flex]
[app.common.geom.shapes.grid-layout :as grid]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.test-helpers.components :as thc]
@@ -19,6 +22,7 @@
[app.common.test-helpers.shapes :as ths]
[app.common.types.component :as ctk]
[app.common.types.shape-tree :as ctst]
[app.common.types.shape.layout :as ctl]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
@@ -332,6 +336,158 @@
(t/is (= (first (:shapes copy-root')) (:id copy-child2')))
(t/is (= (nth (:shapes copy-root') 2) (:id copy-child3')))))
(t/deftest test-sync-grid-cells-when-converting-main-to-grid
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-component-with-many-children-and-copy
:component1
:main-root
[:main-child1 :main-child2 :main-child3]
:copy-root
:main-root-params {:width 400 :height 100}
:main-child-params-list [{:x 0 :y 0 :width 50 :height 50}
{:x 100 :y 0 :width 50 :height 50}
{:x 200 :y 0 :width 50 :height 50}]
:copy-root-params {:children-labels [:copy-child1 :copy-child2 :copy-child3]}))
page (thf/current-page file)
main-root (ths/get-shape file :main-root)
;; ==== Action
;; Convert the main frame into a grid, mirroring the editor's
;; `get-layout-initializer` path (merge layout attrs + calculate-params
;; + assign-cells + reorder-grid-children).
changes1 (cls/generate-update-shapes
(pcb/empty-changes nil (:id page))
#{(:id main-root)}
(fn [shape objects]
(let [children (cfh/get-immediate-children objects (:id shape))
shape (merge shape
{:layout :grid
:layout-grid-dir :row
:layout-grid-rows []
:layout-grid-columns []
:layout-grid-cells {}
:layout-gap {:row-gap 0 :column-gap 0}
:layout-align-items :start
:layout-justify-items :start
:layout-align-content :stretch
:layout-justify-content :stretch
:layout-padding-type :simple
:layout-padding {:p1 0 :p2 0 :p3 0 :p4 0}})
shape (merge shape (grid/calculate-params objects children shape))]
(-> shape
(ctl/assign-cells objects)
ctl/reorder-grid-children)))
(:objects page)
{:with-objects? true})
updated-file (thf/apply-changes file changes1)
updated-main-root (ths/get-shape updated-file :main-root)
changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id updated-file)
(thi/id :component1)
(:id updated-file)
{(:id updated-file) updated-file}
(:id updated-file))
file' (thf/apply-changes updated-file changes2)
;; ==== Get
copy-root' (ths/get-shape file' :copy-root)
copy-child1' (ths/get-shape file' :copy-child1)
copy-child2' (ths/get-shape file' :copy-child2)
copy-child3' (ths/get-shape file' :copy-child3)
ids-map (into {}
(map (juxt :shape-ref :id))
[copy-child1' copy-child2' copy-child3'])
main-cells-remapped (ctl/remap-grid-cells updated-main-root ids-map)
cell-signature
(fn [cells]
(->> cells
vals
(map (fn [cell] [(:row cell) (:column cell) (vec (:shapes cell))]))
set))]
;; ==== Check
(t/is (ctl/grid-layout? copy-root'))
(t/is (= (count (:layout-grid-cells updated-main-root))
(count (:layout-grid-cells copy-root'))))
(t/is (= (cell-signature (:layout-grid-cells main-cells-remapped))
(cell-signature (:layout-grid-cells copy-root'))))
(t/is (= (mapv ids-map (:shapes updated-main-root))
(:shapes copy-root')))))
(t/deftest test-sync-flex-layout-when-converting-main-to-flex
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-component-with-many-children-and-copy
:component1
:main-root
[:main-child1 :main-child2 :main-child3]
:copy-root
:main-root-params {:width 400 :height 100}
:main-child-params-list [{:x 0 :y 0 :width 50 :height 50}
{:x 100 :y 0 :width 50 :height 50}
{:x 200 :y 0 :width 50 :height 50}]
:copy-root-params {:children-labels [:copy-child1 :copy-child2 :copy-child3]}))
page (thf/current-page file)
main-root (ths/get-shape file :main-root)
;; ==== Action
;; Convert the main frame into a flex layout, mirroring the editor's
;; `get-layout-initializer` path.
changes1 (cls/generate-update-shapes
(pcb/empty-changes nil (:id page))
#{(:id main-root)}
(fn [shape objects]
(let [children (cfh/get-immediate-children objects (:id shape))
shape (merge shape
{:layout :flex
:layout-flex-dir :row
:layout-gap-type :multiple
:layout-gap {:row-gap 0 :column-gap 0}
:layout-align-items :start
:layout-justify-content :start
:layout-align-content :stretch
:layout-wrap-type :nowrap
:layout-padding-type :simple
:layout-padding {:p1 0 :p2 0 :p3 0 :p4 0}})]
(merge shape (flex/calculate-params objects children shape))))
(:objects page)
{:with-objects? true})
updated-file (thf/apply-changes file changes1)
updated-main-root (ths/get-shape updated-file :main-root)
changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id updated-file)
(thi/id :component1)
(:id updated-file)
{(:id updated-file) updated-file}
(:id updated-file))
file' (thf/apply-changes updated-file changes2)
;; ==== Get
copy-root' (ths/get-shape file' :copy-root)]
;; ==== Check
(t/is (ctl/flex-layout? copy-root'))
(t/is (= (:layout-flex-dir updated-main-root)
(:layout-flex-dir copy-root')))
(t/is (= (:layout-gap updated-main-root)
(:layout-gap copy-root')))))
(t/deftest test-sync-when-changing-upper
(let [;; ==== Setup
file (-> (thf/sample-file :file1)