🐛 Fix nil deref on missing bounds in layout modifier propagation (#8735)

* 🐛 Fix nil deref on missing bounds in layout modifier propagation

When a parent shape has a child ID in its shapes vector that does
not exist in the objects map, the layout modifier code crashes
because it derefs nil from the bounds map.

The root cause is that children from the parent shapes list are
not validated against the objects map before being passed to the
layout modifier pipeline. Children with missing IDs pass through
unchecked and reach apply-modifiers where bounds lookup fails.

Fix by adding nil guards in apply-modifiers to skip children
without bounds, and changing map to keep to filter them out.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 📎 Add tests for nil bounds in layout modifier propagation

Tests cover flex and grid layout scenarios where a parent
frame has child IDs in its shapes vector that do not exist
in the objects map, verifying that set-objects-modifiers
handles these gracefully without crashing.

Tests:
- Flex layout with normal children (baseline)
- Flex layout with non-existent child in shapes
- Flex layout with only non-existent children
- Grid layout with non-existent child in shapes
- Flex layout resize propagation with ghost children
- Nested flex layout with non-existent child in outer frame

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
Andrey Antukh
2026-03-25 15:36:21 +01:00
committed by GitHub
parent 38bf6c3603
commit 737e04fe2c
3 changed files with 205 additions and 10 deletions

View File

@@ -68,9 +68,10 @@
[modif-tree children objects bounds parent transformed-parent-bounds]
(letfn [(apply-modifiers [bounds child]
[(-> @(get bounds (:id child))
(gpo/parent-coords-bounds @transformed-parent-bounds))
child])
(when-let [child-bounds (get bounds (:id child))]
[(-> @child-bounds
(gpo/parent-coords-bounds @transformed-parent-bounds))
child]))
(set-child-modifiers [[layout-line modif-tree] [child-bounds child]]
(let [[modifiers layout-line]
@@ -83,7 +84,7 @@
(->> children
(keep (d/getf objects))
(remove gco/invalid-geometry?)
(map (partial apply-modifiers bounds)))
(keep (partial apply-modifiers bounds)))
layout-data (gcfl/calc-layout-data parent @transformed-parent-bounds children bounds objects)
children (into [] (cond-> children (not (:reverse? layout-data)) reverse))
@@ -106,9 +107,10 @@
[modif-tree objects bounds parent transformed-parent-bounds]
(letfn [(apply-modifiers [bounds child]
[(-> @(get bounds (:id child))
(gpo/parent-coords-bounds @transformed-parent-bounds))
child])
(when-let [child-bounds (get bounds (:id child))]
[(-> @child-bounds
(gpo/parent-coords-bounds @transformed-parent-bounds))
child]))
(set-child-modifiers [modif-tree grid-data cell-data [child-bounds child]]
(let [modifiers
@@ -119,7 +121,7 @@
children
(->> (cfh/get-immediate-children objects (:id parent) {:remove-hidden true})
(map (partial apply-modifiers bounds)))
(keep (partial apply-modifiers bounds)))
grid-data (gcgl/calc-layout-data parent @transformed-parent-bounds children bounds objects)]
(loop [modif-tree modif-tree
bound+child (first children)
@@ -240,8 +242,10 @@
(gcfl/layout-content-bounds bounds parent children objects)
(ctl/grid-layout? parent)
(let [children (->> children
(map (fn [child] [@(get bounds (:id child)) child])))
(let [children (->> children
(keep (fn [child]
(when-let [child-bounds-ref (get bounds (:id child))]
[@child-bounds-ref child]))))
layout-data (gcgl/calc-layout-data parent @parent-bounds children bounds objects)]
(gcgl/layout-content-bounds bounds parent layout-data))))

View File

@@ -0,0 +1,189 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.geom-modifiers-test
(:require
[app.common.geom.modifiers :as gm]
[app.common.geom.point :as gpt]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.modifiers :as ctm]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
;; ---- Helpers
(defn- add-flex-frame
"Create a flex layout frame"
[file frame-label & {:keys [width height] :as params}]
(ths/add-sample-shape file frame-label
(merge {:type :frame
:name "FlexFrame"
:layout-flex-dir :row
:width (or width 200)
:height (or height 200)}
params)))
(defn- add-rect-child
"Create a rectangle child inside a parent"
[file rect-label parent-label & {:keys [width height x y] :as params}]
(ths/add-sample-shape file rect-label
(merge {:type :rect
:name "Rect"
:parent-label parent-label
:width (or width 50)
:height (or height 50)
:x (or x 0)
:y (or y 0)}
params)))
(defn- add-ghost-child-id
"Add a non-existent child ID to a frame's shapes list.
This simulates data inconsistency where a child ID is referenced
but the child shape doesn't exist in objects."
[file frame-label ghost-id]
(let [page (thf/current-page file)
frame-id (thi/id frame-label)]
(update file :data
(fn [file-data]
(update-in file-data [:pages-index (:id page) :objects frame-id :shapes]
conj ghost-id)))))
;; ---- Tests
(t/deftest flex-layout-with-normal-children
(t/testing "set-objects-modifiers processes flex layout children correctly"
(let [file (-> (thf/sample-file :file1)
(add-flex-frame :frame1)
(add-rect-child :rect1 :frame1))
page (thf/current-page file)
objects (:objects page)
frame-id (thi/id :frame1)
rect-id (thi/id :rect1)
;; Create a move modifier for the rectangle
modif-tree {rect-id {:modifiers (ctm/move-modifiers (gpt/point 10 20))}}
;; This should not crash
result (gm/set-objects-modifiers modif-tree objects)]
(t/is (some? result))
;; The rectangle should have modifiers
(t/is (contains? result rect-id)))))
(t/deftest flex-layout-with-nonexistent-child
(t/testing "set-objects-modifiers handles flex frame with non-existent child in shapes"
(let [ghost-id (thi/next-uuid)
file (-> (thf/sample-file :file1)
(add-flex-frame :frame1)
(add-rect-child :rect1 :frame1)
;; Add a non-existent child ID to the frame's shapes
(add-ghost-child-id :frame1 ghost-id))
page (thf/current-page file)
objects (:objects page)
rect-id (thi/id :rect1)
;; Create a move modifier for the existing rectangle
modif-tree {rect-id {:modifiers (ctm/move-modifiers (gpt/point 10 20))}}
;; This should NOT crash even though the flex frame has
;; a child ID (ghost-id) that doesn't exist in objects
result (gm/set-objects-modifiers modif-tree objects)]
(t/is (some? result))
(t/is (contains? result rect-id)))))
(t/deftest flex-layout-with-all-ghost-children
(t/testing "set-objects-modifiers handles flex frame with only non-existent children"
(let [ghost1 (thi/next-uuid)
ghost2 (thi/next-uuid)
file (-> (thf/sample-file :file1)
(add-flex-frame :frame1)
;; Add only non-existent children to the frame's shapes
(add-ghost-child-id :frame1 ghost1)
(add-ghost-child-id :frame1 ghost2))
page (thf/current-page file)
objects (:objects page)
frame-id (thi/id :frame1)
;; Create a move modifier for the frame itself
modif-tree {frame-id {:modifiers (ctm/move-modifiers (gpt/point 5 5))}}
;; Should not crash even though the flex frame has
;; no existing children in its shapes list
result (gm/set-objects-modifiers modif-tree objects)]
(t/is (some? result)))))
(t/deftest grid-layout-with-nonexistent-child
(t/testing "set-objects-modifiers handles grid frame with non-existent child in shapes"
(let [ghost-id (thi/next-uuid)
file (-> (thf/sample-file :file1)
(ths/add-sample-shape :frame1
{:type :frame
:name "GridFrame"
:layout-grid-dir :row
:width 200
:height 200})
(add-rect-child :rect1 :frame1)
(add-ghost-child-id :frame1 ghost-id))
page (thf/current-page file)
objects (:objects page)
rect-id (thi/id :rect1)
modif-tree {rect-id {:modifiers (ctm/move-modifiers (gpt/point 10 20))}}
;; Should not crash for grid layout with ghost child
result (gm/set-objects-modifiers modif-tree objects)]
(t/is (some? result))
(t/is (contains? result rect-id)))))
(t/deftest flex-layout-resize-with-nonexistent-child
(t/testing "resize modifier propagation handles non-existent children"
(let [ghost-id (thi/next-uuid)
file (-> (thf/sample-file :file1)
(add-flex-frame :frame1)
(add-rect-child :rect1 :frame1)
(add-ghost-child-id :frame1 ghost-id))
page (thf/current-page file)
objects (:objects page)
frame-id (thi/id :frame1)
;; Create a resize modifier for the frame itself
modif-tree {frame-id {:modifiers (ctm/resize-modifiers
(gpt/point 2 2)
(gpt/point 0 0))}}
;; Should not crash when propagating resize through flex layout
;; that has ghost children
result (gm/set-objects-modifiers modif-tree objects)]
(t/is (some? result))
;; The frame should have modifiers
(t/is (contains? result frame-id)))))
(t/deftest nested-flex-layout-with-nonexistent-child
(t/testing "nested flex layout handles non-existent children in outer frame"
(let [ghost-id (thi/next-uuid)
file (-> (thf/sample-file :file1)
(add-flex-frame :outer-frame)
(add-flex-frame :inner-frame :parent-label :outer-frame)
(add-rect-child :rect1 :inner-frame)
(add-ghost-child-id :outer-frame ghost-id))
page (thf/current-page file)
objects (:objects page)
rect-id (thi/id :rect1)
modif-tree {rect-id {:modifiers (ctm/move-modifiers (gpt/point 5 10))}}
result (gm/set-objects-modifiers modif-tree objects)]
(t/is (some? result))
(t/is (contains? result rect-id)))))

View File

@@ -12,6 +12,7 @@
[common-tests.data-test]
[common-tests.files-changes-test]
[common-tests.files-migrations-test]
[common-tests.geom-modifiers-test]
[common-tests.geom-point-test]
[common-tests.geom-shapes-test]
[common-tests.geom-test]
@@ -66,6 +67,7 @@
'common-tests.data-test
'common-tests.files-changes-test
'common-tests.files-migrations-test
'common-tests.geom-modifiers-test
'common-tests.geom-point-test
'common-tests.geom-shapes-test
'common-tests.geom-test