diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index b6038e6858..b579b89575 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -99,26 +99,33 @@ ;; Never call this directly but through the data-event `:layout/update` ;; Otherwise a lot of cycle dependencies could be generated (defn- update-layout-positions - [{:keys [page-id ids undo-group]}] + [{:keys [page-id ids undo-group drain-ids]}] (ptk/reify ::update-layout-positions ptk/WatchEvent (watch [_ state _] (let [page-id (or page-id (:current-page-id state)) objects (dsh/lookup-page-objects state page-id) - ids (->> ids (remove uuid/zero?) (filter #(contains? objects %)))] - (if (d/not-empty? ids) - (let [modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))] - (if (features/active-feature? state "render-wasm/v1") - (rx/of (dwm/apply-wasm-modifiers modif-tree + ids (->> ids (remove uuid/zero?) (filter #(contains? objects %))) + + update-positions-stream + (if (d/not-empty? ids) + (let [modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))] + (if (features/active-feature? state "render-wasm/v1") + (rx/of (dwm/apply-wasm-modifiers modif-tree + :stack-undo? true + :undo-group undo-group + :ignore-touched true)) + (rx/of (dwm/apply-modifiers {:page-id page-id + :modifiers modif-tree :stack-undo? true - :undo-group undo-group - :ignore-touched true)) - (rx/of (dwm/apply-modifiers {:page-id page-id - :modifiers modif-tree - :stack-undo? true - :ignore-touched true - :undo-group undo-group})))) - (rx/empty)))))) + :ignore-touched true + :undo-group undo-group})))) + (rx/empty))] + + (cond->> update-positions-stream + ;; Drain the pending-reflow marks only after the apply events above are processed + (d/not-empty? drain-ids) + (rx/finalize #(wrf/mark-done! :layout drain-ids))))))) (defn initialize-shape-layout [] @@ -138,15 +145,15 @@ ;; they are process together. It will get a better performance. (rx/buffer-time 100) (rx/filter #(d/not-empty? %)) - ;; Balance the per-event marks: decrement once per buffered event - ;; (multiplicity preserved via mapcat, no deduping). - (rx/tap #(wrf/mark-done! :layout (mapcat :ids %))) (rx/mapcat (fn [data] (->> (group-by :page-id data) (map (fn [[page-id items]] - (let [ids (reduce #(into %1 (:ids %2)) #{} items)] - (update-layout-positions {:page-id page-id :ids ids}))))))) + (let [ids (reduce #(into %1 (:ids %2)) #{} items) + drain-ids (mapcat :ids items)] + (update-layout-positions {:page-id page-id + :ids ids + :drain-ids drain-ids}))))))) (rx/take-until stopper) ;; On workspace teardown clear everything still pending. (rx/finalize wrf/reset-pending!)))))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 2d61d618b2..f3dddc51a5 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -420,7 +420,8 @@ (wrf/mark-pending! :font [id]) (let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)] (->> stream - (rx/filter (ptk/type? ::commit-position-data)) + (rx/filter (ptk/type? ::position-data-committed)) + (rx/filter #(contains? (:ids (deref %)) id)) (rx/take 1) ;; Timeout so the shape cannot stay pending forever (rx/timeout stuck-timeout (rx/of :timeout)) @@ -793,6 +794,8 @@ (-> shape (assoc :position-data (get position-data (:id shape))))) {:stack-undo? true :reg-objects? false})) + (rx/of (ptk/data-event ::position-data-committed + {:ids (into #{} (keys position-data))})) (rx/of (fn [state] (dissoc state ::update-position-data-debounce ::update-position-data)))))))) @@ -897,7 +900,8 @@ (wrf/mark-pending! :font [id]) (let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)] (->> stream - (rx/filter (ptk/type? ::commit-position-data)) + (rx/filter (ptk/type? ::position-data-committed)) + (rx/filter #(contains? (:ids (deref %)) id)) (rx/take 1) ;; Timeout so the shape cannot stay pending forever (rx/timeout stuck-timeout (rx/of :timeout)) diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 6be55c6f70..0aa76127ad 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -201,18 +201,24 @@ (every? (fn [font] (let [font-data (wasm.fonts/make-font-data font)] - (wasm.fonts/font-stored? font-data (:emoji? font-data))))))] + (wasm.fonts/font-stored? font-data (:emoji? font-data)))))) - (if fonts-loaded? - (let [pass-opts (when (or (some? undo-group) (some? undo-id)) - (cond-> {} - (some? undo-group) (assoc :undo-group undo-group) - (some? undo-id) (assoc :undo-id undo-id)))] - (rx/of (resize-wasm-text-debounce-inner id pass-opts))) - ;; Fonts not loaded; retry after 20 msecs - (->> (rx/of (resize-wasm-text-debounce id opts)) - (rx/delay 20)))))))) + resize-wasm-stream + (if fonts-loaded? + (let [pass-opts (when (or (some? undo-group) (some? undo-id)) + (cond-> {} + (some? undo-group) (assoc :undo-group undo-group) + (some? undo-id) (assoc :undo-id undo-id)))] + (rx/of (resize-wasm-text-debounce-inner id pass-opts))) + + ;; Fonts not loaded; retry after 20 msecs + (->> (rx/of (resize-wasm-text-debounce id opts)) + (rx/delay 20)))] + + (wrf/mark-pending! :text-resize [id]) + (->> resize-wasm-stream + (rx/finalize #(wrf/mark-done! :text-resize [id])))))))) (defn resize-wasm-text-all "Resize all text shapes (auto-width/auto-height) from a collection of ids." diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs index 4529e34582..eb74bca4ac 100644 --- a/frontend/test/frontend_tests/helpers/wasm.cljs +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -161,12 +161,26 @@ "Stores the original WASM function values so they can be restored." (atom {})) +;; Nesting depth of setup/teardown pairs. Only the outermost setup saves the +;; originals and installs the mocks, and only the outermost teardown restores +;; them, so a `with-wasm-mocks*` inside a fixture that already installed the +;; mocks does not capture mocks as "originals" and leak them. +(def ^:private install-depth (atom 0)) + +(declare install-wasm-mocks!) + (defn setup-wasm-mocks! "Install WASM mocks via `set!` that persist across async boundaries. - Also resets call counts. Call `teardown-wasm-mocks!` to restore." + Also resets call counts. Call `teardown-wasm-mocks!` to restore. + Nested calls are no-ops apart from resetting call counts." [] ;; Reset call tracking (reset-call-counts!) + (when (= 1 (swap! install-depth inc)) + (install-wasm-mocks!))) + +(defn- install-wasm-mocks! + [] ;; Save originals (reset! originals {:initialized? wasm.api/initialized? @@ -201,12 +215,16 @@ (set! wasm.fonts/make-font-data mock-make-font-data) (set! wasm.fonts/get-content-fonts mock-get-content-fonts)) +(declare restore-wasm-mocks!) + (defn teardown-wasm-mocks! "Restore the original WASM functions saved by `setup-wasm-mocks!`. - No-op when there is nothing to restore (`originals` empty): restoring from an - empty snapshot would `set!` every WASM function to nil, breaking any later - code that calls them (e.g. a leaked debounced event firing during a - subsequent test namespace)." + Nested calls are no-ops; only the outermost teardown restores." + [] + (when (zero? (swap! install-depth #(max 0 (dec %)))) + (restore-wasm-mocks!))) + +(defn- restore-wasm-mocks! [] (when-let [orig (not-empty @originals)] (set! wasm.api/initialized? (:initialized? orig)) diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index ad5ffea89d..00ff920f77 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -420,8 +420,15 @@ (def ^:private zero-id "00000000-0000-0000-0000-000000000000") (t/use-fixtures :each - {:before wrf/reset-pending! + {:before (fn [] + ;; Keep the WASM mocks installed across the async test bodies: + ;; debounced text-resize timers leaked by earlier test namespaces + ;; can fire inside this namespace's async waits, and in Node they + ;; would otherwise reach the real (unavailable) WASM API. + (thw/setup-wasm-mocks!) + (wrf/reset-pending!)) :after (fn [] + (thw/teardown-wasm-mocks!) (wrf/reset-pending!) (set! st/state original-st-state) (set! st/stream original-st-stream))}) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index f064f234e0..de94a08f21 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -262,7 +262,7 @@ (fn [id theme] (swap! captured conj {:id id :theme theme}) :update-token-theme) - st/emit! identity] + st/emit! mock/noop] (.addSet theme set) (.removeSet theme set) (t/is (= [theme-id theme-id] (mapv :id @captured))) diff --git a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json index 0cbb2f8e1d..ff9efa2e24 100644 --- a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json +++ b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json @@ -1,7 +1,14 @@ { "interfaces": { - "ActiveUser": ["position", "zoom"], - "Blur": ["hidden", "id", "value"], + "ActiveUser": [ + "position", + "zoom" + ], + "Blur": [ + "hidden", + "id", + "value" + ], "Board": [ "addFlexLayout", "addGridLayout", @@ -31,8 +38,17 @@ "insertChild", "type" ], - "Bounds": ["height", "width", "x", "y"], - "CloseOverlay": ["animation", "destination", "type"], + "Bounds": [ + "height", + "width", + "x", + "y" + ], + "CloseOverlay": [ + "animation", + "destination", + "type" + ], "Color": [ "color", "fileId", @@ -43,9 +59,20 @@ "opacity", "path" ], - "ColorShapeInfo": ["shapesInfo"], - "ColorShapeInfoEntry": ["index", "property", "shapeId"], - "Comment": ["content", "date", "remove", "user"], + "ColorShapeInfo": [ + "shapesInfo" + ], + "ColorShapeInfoEntry": [ + "index", + "property", + "shapeId" + ], + "Comment": [ + "content", + "date", + "remove", + "user" + ], "CommentThread": [ "board", "findComments", @@ -114,9 +141,12 @@ "uploadMediaData", "uploadMediaUrl", "version", - "viewport" + "viewport", + "waitForLayoutUpdate" + ], + "ContextGeometryUtils": [ + "center" ], - "ContextGeometryUtils": ["center"], "ContextTypesUtils": [ "isBoard", "isBool", @@ -130,9 +160,19 @@ "isVariantComponent", "isVariantContainer" ], - "ContextUtils": ["geometry", "types"], - "Dissolve": ["duration", "easing", "type"], - "Ellipse": ["fills", "type"], + "ContextUtils": [ + "geometry", + "types" + ], + "Dissolve": [ + "duration", + "easing", + "type" + ], + "Ellipse": [ + "fills", + "type" + ], "EventsMap": [ "contentsave", "filechange", @@ -142,7 +182,12 @@ "shapechange", "themechange" ], - "Export": ["scale", "skipChildren", "suffix", "type"], + "Export": [ + "scale", + "skipChildren", + "suffix", + "type" + ], "File": [ "export", "findVersions", @@ -171,9 +216,21 @@ "fillImage", "fillOpacity" ], - "Flags": ["naturalChildOrdering", "throwValidationErrors"], - "FlexLayout": ["appendChild", "dir", "wrap"], - "Flow": ["name", "page", "remove", "startingBoard"], + "Flags": [ + "naturalChildOrdering", + "throwValidationErrors" + ], + "FlexLayout": [ + "appendChild", + "dir", + "wrap" + ], + "Flow": [ + "name", + "page", + "remove", + "startingBoard" + ], "Font": [ "applyToRange", "applyToText", @@ -192,8 +249,21 @@ "findById", "findByName" ], - "FontVariant": ["fontStyle", "fontVariantId", "fontWeight", "name"], - "Gradient": ["endX", "endY", "startX", "startY", "stops", "type", "width"], + "FontVariant": [ + "fontStyle", + "fontVariantId", + "fontWeight", + "name" + ], + "Gradient": [ + "endX", + "endY", + "startX", + "startY", + "stops", + "type", + "width" + ], "GridLayout": [ "addColumn", "addColumnAtIndex", @@ -217,7 +287,11 @@ "removeMask", "type" ], - "GuideColumn": ["display", "params", "type"], + "GuideColumn": [ + "display", + "params", + "type" + ], "GuideColumnParams": [ "color", "gutter", @@ -226,10 +300,24 @@ "size", "type" ], - "GuideRow": ["display", "params", "type"], - "GuideSquare": ["display", "params", "type"], - "GuideSquareParams": ["color", "size"], - "HistoryContext": ["undoBlockBegin", "undoBlockFinish"], + "GuideRow": [ + "display", + "params", + "type" + ], + "GuideSquare": [ + "display", + "params", + "type" + ], + "GuideSquareParams": [ + "color", + "size" + ], + "HistoryContext": [ + "undoBlockBegin", + "undoBlockFinish" + ], "ImageData": [ "data", "height", @@ -239,7 +327,13 @@ "name", "width" ], - "Interaction": ["action", "delay", "remove", "shape", "trigger"], + "Interaction": [ + "action", + "delay", + "remove", + "shape", + "trigger" + ], "LayoutCellProperties": [ "areaName", "column", @@ -297,7 +391,12 @@ "connected", "local" ], - "LibraryElement": ["id", "libraryId", "name", "path"], + "LibraryElement": [ + "id", + "libraryId", + "name", + "path" + ], "LibrarySummary": [ "id", "name", @@ -326,15 +425,25 @@ "variantProps", "variants" ], - "LocalStorage": ["getItem", "getKeys", "removeItem", "setItem"], + "LocalStorage": [ + "getItem", + "getKeys", + "removeItem", + "setItem" + ], "NavigateTo": [ "animation", "destination", "preserveScrollPosition", "type" ], - "OpenOverlay": ["type"], - "OpenUrl": ["type", "url"], + "OpenOverlay": [ + "type" + ], + "OpenUrl": [ + "type", + "url" + ], "OverlayAction": [ "addBackgroundOverlay", "animation", @@ -361,9 +470,23 @@ "root", "rulerGuides" ], - "Path": ["commands", "d", "fills", "type"], - "PathCommand": ["command", "params"], - "Penpot": ["closePlugin", "off", "on", "ui", "utils"], + "Path": [ + "commands", + "d", + "fills", + "type" + ], + "PathCommand": [ + "command", + "params" + ], + "Penpot": [ + "closePlugin", + "off", + "on", + "ui", + "utils" + ], "PluginData": [ "getPluginData", "getPluginDataKeys", @@ -372,11 +495,28 @@ "setPluginData", "setSharedPluginData" ], - "Point": ["x", "y"], - "PreviousScreen": ["type"], - "Push": ["direction", "duration", "easing", "type"], - "Rectangle": ["fills", "type"], - "RulerGuide": ["board", "orientation", "position", "remove"], + "Point": [ + "x", + "y" + ], + "PreviousScreen": [ + "type" + ], + "Push": [ + "direction", + "duration", + "easing", + "type" + ], + "Rectangle": [ + "fills", + "type" + ], + "RulerGuide": [ + "board", + "orientation", + "position" + ], "Shadow": [ "blur", "color", @@ -454,11 +594,19 @@ "switchVariant", "tokens", "visible", + "waitForLayoutUpdate", "width", "x", "y" ], - "Slide": ["direction", "duration", "easing", "offsetEffect", "type", "way"], + "Slide": [ + "direction", + "duration", + "easing", + "offsetEffect", + "type", + "way" + ], "Stroke": [ "strokeAlignment", "strokeCapEnd", @@ -472,7 +620,9 @@ "strokeStyle", "strokeWidth" ], - "SvgRaw": ["type"], + "SvgRaw": [ + "type" + ], "Text": [ "align", "applyTypography", @@ -513,7 +663,9 @@ "textTransform", "verticalAlign" ], - "ToggleOverlay": ["type"], + "ToggleOverlay": [ + "type" + ], "TokenBase": [ "applyToSelected", "applyToShapes", @@ -524,8 +676,16 @@ "remove", "resolvedValueString" ], - "TokenBorderRadius": ["resolvedValue", "type", "value"], - "TokenBorderWidth": ["resolvedValue", "type", "value"], + "TokenBorderRadius": [ + "resolvedValue", + "type", + "value" + ], + "TokenBorderWidth": [ + "resolvedValue", + "type", + "value" + ], "TokenCatalog": [ "addSet", "addTheme", @@ -534,15 +694,51 @@ "sets", "themes" ], - "TokenColor": ["resolvedValue", "type", "value"], - "TokenDimension": ["resolvedValue", "type", "value"], - "TokenFontFamilies": ["resolvedValue", "type", "value"], - "TokenFontSizes": ["resolvedValue", "type", "value"], - "TokenFontWeights": ["resolvedValue", "type", "value"], - "TokenLetterSpacing": ["resolvedValue", "type", "value"], - "TokenNumber": ["resolvedValue", "type", "value"], - "TokenOpacity": ["resolvedValue", "type", "value"], - "TokenRotation": ["resolvedValue", "type", "value"], + "TokenColor": [ + "resolvedValue", + "type", + "value" + ], + "TokenDimension": [ + "resolvedValue", + "type", + "value" + ], + "TokenFontFamilies": [ + "resolvedValue", + "type", + "value" + ], + "TokenFontSizes": [ + "resolvedValue", + "type", + "value" + ], + "TokenFontWeights": [ + "resolvedValue", + "type", + "value" + ], + "TokenLetterSpacing": [ + "resolvedValue", + "type", + "value" + ], + "TokenNumber": [ + "resolvedValue", + "type", + "value" + ], + "TokenOpacity": [ + "resolvedValue", + "type", + "value" + ], + "TokenRotation": [ + "resolvedValue", + "type", + "value" + ], "TokenSet": [ "active", "addToken", @@ -555,7 +751,11 @@ "tokens", "tokensByType" ], - "TokenShadow": ["resolvedValue", "type", "value"], + "TokenShadow": [ + "resolvedValue", + "type", + "value" + ], "TokenShadowValue": [ "blur", "color", @@ -572,10 +772,26 @@ "offsetY", "spread" ], - "TokenSizing": ["resolvedValue", "type", "value"], - "TokenSpacing": ["resolvedValue", "type", "value"], - "TokenTextCase": ["resolvedValue", "type", "value"], - "TokenTextDecoration": ["resolvedValue", "type", "value"], + "TokenSizing": [ + "resolvedValue", + "type", + "value" + ], + "TokenSpacing": [ + "resolvedValue", + "type", + "value" + ], + "TokenTextCase": [ + "resolvedValue", + "type", + "value" + ], + "TokenTextDecoration": [ + "resolvedValue", + "type", + "value" + ], "TokenTheme": [ "active", "activeSets", @@ -589,7 +805,11 @@ "removeSet", "toggleActive" ], - "TokenTypography": ["resolvedValue", "type", "value"], + "TokenTypography": [ + "resolvedValue", + "type", + "value" + ], "TokenTypographyValue": [ "fontFamilies", "fontSizes", @@ -608,9 +828,20 @@ "textCase", "textDecoration" ], - "Track": ["type", "value"], - "User": ["avatarUrl", "color", "id", "name", "sessionId"], - "VariantContainer": ["variants"], + "Track": [ + "type", + "value" + ], + "User": [ + "avatarUrl", + "color", + "id", + "name", + "sessionId" + ], + "VariantContainer": [ + "variants" + ], "Variants": [ "addProperty", "addVariant", @@ -1213,6 +1444,12 @@ "type": null, "array": false }, + "waitForLayoutUpdate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "getPluginData": { "decl": "PluginData", "kind": "method", @@ -1713,6 +1950,12 @@ "type": null, "array": false }, + "waitForLayoutUpdate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "getPluginData": { "decl": "PluginData", "kind": "method", @@ -2312,6 +2555,12 @@ "kind": "method", "type": "VariantContainer", "array": false + }, + "waitForLayoutUpdate": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false } }, "ContextGeometryUtils": { @@ -2845,6 +3094,12 @@ "type": null, "array": false }, + "waitForLayoutUpdate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "getPluginData": { "decl": "PluginData", "kind": "method", @@ -4087,6 +4342,12 @@ "type": null, "array": false }, + "waitForLayoutUpdate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "getPluginData": { "decl": "PluginData", "kind": "method", @@ -4671,6 +4932,12 @@ "type": null, "array": false }, + "waitForLayoutUpdate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "getPluginData": { "decl": "PluginData", "kind": "method", @@ -6299,6 +6566,12 @@ "type": null, "array": false }, + "waitForLayoutUpdate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "getPluginData": { "decl": "PluginData", "kind": "method", @@ -6620,6 +6893,12 @@ "kind": "method", "type": "VariantContainer", "array": false + }, + "waitForLayoutUpdate": { + "decl": "Context", + "kind": "method", + "type": null, + "array": false } }, "PluginData": { @@ -7129,6 +7408,12 @@ "type": null, "array": false }, + "waitForLayoutUpdate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "getPluginData": { "decl": "PluginData", "kind": "method", @@ -7657,6 +7942,12 @@ "type": null, "array": false }, + "waitForLayoutUpdate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "getPluginData": { "decl": "PluginData", "kind": "method", @@ -8221,6 +8512,12 @@ "type": null, "array": false }, + "waitForLayoutUpdate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "getPluginData": { "decl": "PluginData", "kind": "method", @@ -8787,6 +9084,12 @@ "type": null, "array": false }, + "waitForLayoutUpdate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "getPluginData": { "decl": "PluginData", "kind": "method", @@ -11085,6 +11388,12 @@ "type": null, "array": false }, + "waitForLayoutUpdate": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "getPluginData": { "decl": "PluginData", "kind": "method", @@ -11240,7 +11549,11 @@ } }, "Animation": { - "variants": ["Dissolve", "Slide", "Push"], + "variants": [ + "Dissolve", + "Slide", + "Push" + ], "discriminant": { "field": "type", "map": { @@ -11251,7 +11564,11 @@ } }, "Guide": { - "variants": ["GuideColumn", "GuideRow", "GuideSquare"], + "variants": [ + "GuideColumn", + "GuideRow", + "GuideSquare" + ], "discriminant": { "field": "type", "map": { @@ -11332,7 +11649,10 @@ } }, "TokenValueString": { - "variants": ["TokenShadowValueString", "TokenTypographyValueString"], + "variants": [ + "TokenShadowValueString", + "TokenTypographyValueString" + ], "discriminant": null } } diff --git a/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts new file mode 100644 index 0000000000..6a8a36cbc7 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts @@ -0,0 +1,399 @@ +import { expect, expectReject } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import type { Board, Font, Shape, Text } from '@penpot/plugin-types'; +import type { TestContext } from '../framework/types'; + +// waitForLayoutUpdate (context-level and per-shape). +// The promise must resolve only once the pending reflow work has been applied, +// so every test asserts the *observable geometry* after awaiting: if the +// promise settles too early the read-back is stale and the test goes red. +// Reflow work is scheduled by three pipelines: flex/grid layout updates +// (buffered ~100ms), wasm text resizes (debounced), and font-change +// re-measurement. The probes below cover each one, including cascaded reflows +// (a child resize that must also re-hug its ancestors) and waits racing the +// font-loading retry loop. + +function flexBoard(ctx: TestContext): Board { + const b = ctx.penpot.createBoard(); + ctx.board.appendChild(b); + return b; +} + +/** Creates a horizontal flex board with two 50x50 rects and a 10px gap. */ +function flexRow(ctx: TestContext): { + board: Board; + rects: [Shape, Shape]; +} { + const b = flexBoard(ctx); + const flex = b.addFlexLayout(); + flex.dir = 'row'; + flex.columnGap = 10; + const r1 = ctx.penpot.createRectangle(); + r1.resize(50, 50); + flex.appendChild(r1); + const r2 = ctx.penpot.createRectangle(); + r2.resize(50, 50); + flex.appendChild(r2); + return { board: b, rects: [r1, r2] }; +} + +/** Orders the two rects of a flex row by their current x position. */ +function byX(rects: [Shape, Shape]): { left: Shape; right: Shape } { + const [a, b] = rects; + return a.x <= b.x ? { left: a, right: b } : { left: b, right: a }; +} + +/** + * Picks a font from the end of the catalog that differs from the text's + * current font, so assigning it triggers a real (not-yet-loaded) font fetch. + * `offset` lets consecutive tests pick distinct fonts: fonts loaded by an + * earlier test stay cached for the whole run and would drain the pending mark + * near-instantly if reused. + */ +function unloadedFont(ctx: TestContext, t: Text, offset: number): Font { + const all = ctx.penpot.fonts.all; + for (let i = all.length - 1 - offset; i >= 0; i--) { + const f = all[i]; + if (f.fontId !== t.fontId && f.variants.length > 0) return f; + } + throw new Error('no alternative font available'); +} + +function autoWidthText(ctx: TestContext, value: string): Text { + const t = ctx.penpot.createText(value); + if (!t) throw new Error('createText returned null'); + ctx.board.appendChild(t); + t.growType = 'auto-width'; + t.fontSize = '36'; + return t; +} + +describe('WaitForLayoutUpdate', () => { + describe('Basics', () => { + test('context method returns a promise that resolves', async (ctx) => { + const p = ctx.penpot.waitForLayoutUpdate(); + expect(typeof p.then).toBe('function'); + const value = await p; + expect(value).toBeUndefined(); + }); + + // For the next two, resolving (not rejecting, not hitting the runner + // timeout) is itself the behaviour under test, so they have no expects. + + test('a timeout wait resolves via the fast path when nothing is pending', async (ctx) => { + // Drain any residual work first; once the untimed wait resolves the + // pending map is empty, so the settle signal replays synchronously and + // must win the race against the deadline: even a 1ms timeout has to + // resolve, never reject. + await ctx.penpot.waitForLayoutUpdate(); + await ctx.penpot.waitForLayoutUpdate(1); + }); + + test('shape method resolves for a shape with no pending work', async (ctx) => { + const r = ctx.penpot.createRectangle(); + ctx.board.appendChild(r); + await r.waitForLayoutUpdate(); + }); + + test('consecutive waits each track their own pending work', async (ctx) => { + // Each call opens a fresh subscription to the pending map, so a wait must + // settle on the work scheduled right before it and never carry state from + // an earlier, already-resolved wait. Two successive resizes to different + // sizes prove it: the second wait has to reflect the second reflow. + const { rects } = flexRow(ctx); + await ctx.penpot.waitForLayoutUpdate(); + + const { left, right } = byX(rects); + left.resize(120, 50); + await ctx.penpot.waitForLayoutUpdate(); + expect(right.x - left.x).toBeCloseTo(130, 0); + + left.resize(70, 50); + await ctx.penpot.waitForLayoutUpdate(); + expect(right.x - left.x).toBeCloseTo(80, 0); + }); + }); + + describe('Flex reflow', () => { + test('sibling is repositioned after awaiting a child resize', async (ctx) => { + const { rects } = flexRow(ctx); + await ctx.penpot.waitForLayoutUpdate(); + + const { left, right } = byX(rects); + left.resize(120, 50); + await ctx.penpot.waitForLayoutUpdate(); + expect(right.x - left.x).toBeCloseTo(130, 0); + }); + + test('board-level wait settles the reflow of its layout', async (ctx) => { + const { board: b, rects } = flexRow(ctx); + await b.waitForLayoutUpdate(); + + const { left, right } = byX(rects); + left.resize(120, 50); + // The layout pipeline marks the laid-out board (the parent) as pending, + // so the board proxy is the shape-level handle for this reflow. + await b.waitForLayoutUpdate(); + expect(right.x - left.x).toBeCloseTo(130, 0); + }); + + test('back-to-back resizes settle to the last value', async (ctx) => { + // Overlapping operations on the same shapes: the wait must not resolve + // after the first one drains while the second is still in flight. + const { rects } = flexRow(ctx); + await ctx.penpot.waitForLayoutUpdate(); + + const { left, right } = byX(rects); + left.resize(80, 50); + left.resize(120, 50); + await ctx.penpot.waitForLayoutUpdate(); + expect(right.x - left.x).toBeCloseTo(130, 0); + }); + + test('grid layout reflow settles child positions', async (ctx) => { + const b = flexBoard(ctx); + const grid = b.addGridLayout(); + grid.addRow('fixed', 60); + grid.addColumn('fixed', 60); + grid.addColumn('fixed', 60); + const r1 = ctx.penpot.createRectangle(); + r1.resize(50, 50); + grid.appendChild(r1, 1, 1); + const r2 = ctx.penpot.createRectangle(); + r2.resize(50, 50); + grid.appendChild(r2, 1, 2); + await ctx.penpot.waitForLayoutUpdate(); + // Cells are 60px wide, so the second child sits one track further right. + expect(Math.abs(r2.x - r1.x)).toBeCloseTo(60, 0); + }); + }); + + describe('Hug sizing', () => { + test('hug board fits its content after awaiting the initial layout', async (ctx) => { + const { board: b } = flexRow(ctx); + const flex = b.flex; + if (!flex) throw new Error('expected a flex layout on the board'); + flex.horizontalSizing = 'auto'; + flex.verticalSizing = 'auto'; + await ctx.penpot.waitForLayoutUpdate(); + // 50 + 10 gap + 50, no padding. + expect(b.width).toBeCloseTo(110, 0); + }); + + test('hug board grows after awaiting a child resize', async (ctx) => { + const { board: b, rects } = flexRow(ctx); + const flex = b.flex; + if (!flex) throw new Error('expected a flex layout on the board'); + flex.horizontalSizing = 'auto'; + flex.verticalSizing = 'auto'; + await ctx.penpot.waitForLayoutUpdate(); + + const { left } = byX(rects); + left.resize(120, 50); + await ctx.penpot.waitForLayoutUpdate(); + expect(b.width).toBeCloseTo(180, 0); + }); + + test('nested hug boards both grow after awaiting a child resize', async (ctx) => { + // Cascade probe: the child resize reflows the inner board, whose new + // size must then reflow the outer board. The wait has to cover the whole + // chain, not just the first reflow round. + const outer = flexBoard(ctx); + const outerFlex = outer.addFlexLayout(); + outerFlex.dir = 'row'; + outerFlex.horizontalSizing = 'auto'; + outerFlex.verticalSizing = 'auto'; + + const inner = ctx.penpot.createBoard(); + outerFlex.appendChild(inner); + const innerFlex = inner.addFlexLayout(); + innerFlex.dir = 'row'; + innerFlex.horizontalSizing = 'auto'; + innerFlex.verticalSizing = 'auto'; + + const rect = ctx.penpot.createRectangle(); + rect.resize(50, 50); + innerFlex.appendChild(rect); + await ctx.penpot.waitForLayoutUpdate(); + expect(inner.width).toBeCloseTo(50, 0); + expect(outer.width).toBeCloseTo(50, 0); + + rect.resize(200, 50); + await ctx.penpot.waitForLayoutUpdate(); + expect(inner.width).toBeCloseTo(200, 0); + expect(outer.width).toBeCloseTo(200, 0); + }); + + test('hug board hugs an auto-width text after a characters change', async (ctx) => { + // Cascade probe across pipelines: the text resize is scheduled by the + // text pipeline and only then does the board reflow to hug it. + const b = flexBoard(ctx); + const flex = b.addFlexLayout(); + flex.horizontalSizing = 'auto'; + flex.verticalSizing = 'auto'; + const t = ctx.penpot.createText('hi'); + if (!t) throw new Error('createText returned null'); + flex.appendChild(t); + t.growType = 'auto-width'; + await ctx.penpot.waitForLayoutUpdate(); + const w0 = b.width; + + t.characters = 'a much much much longer text content than before'; + await ctx.penpot.waitForLayoutUpdate(); + expect(t.width).toBeGreaterThan(w0); + expect(b.width).toBeCloseTo(t.width, 0); + }); + }); + + describe('Text', () => { + test('auto-width text grows after awaiting a fontSize bump', async (ctx) => { + const t = autoWidthText(ctx, 'The quick brown fox'); + await ctx.penpot.waitForLayoutUpdate(); + const w0 = t.width; + + t.fontSize = '72'; + await ctx.penpot.waitForLayoutUpdate(); + expect(t.width).toBeGreaterThan(w0); + }); + + test('auto-width text grows after awaiting a characters change', async (ctx) => { + const t = autoWidthText(ctx, 'short'); + await ctx.penpot.waitForLayoutUpdate(); + const w0 = t.width; + + t.characters = 'short plus a considerably longer tail 0123456789'; + await ctx.penpot.waitForLayoutUpdate(); + expect(t.width).toBeGreaterThan(w0); + }); + + test('shape-level wait covers the text own resize', async (ctx) => { + const t = autoWidthText(ctx, 'The quick brown fox'); + await t.waitForLayoutUpdate(); + const w0 = t.width; + + t.fontSize = '72'; + await t.waitForLayoutUpdate(); + expect(t.width).toBeGreaterThan(w0); + }); + + test('auto-width text is re-measured after awaiting a font change', async (ctx) => { + // Probe for the font-loading window: right after a font change the new + // font is typically not loaded yet, and the wait must cover the load + + // re-measure, not resolve while the loader is still retrying. + const t = autoWidthText( + ctx, + 'The quick brown fox jumps over the lazy dog 0123456789', + ); + await ctx.penpot.waitForLayoutUpdate(); + const w0 = t.width; + + const font = ctx.penpot.fonts.all.find( + (f) => f.fontId !== t.fontId && f.variants.length > 0, + ); + if (!font) throw new Error('no alternative font available'); + t.fontId = font.fontId; + t.fontFamily = font.fontFamily; + await ctx.penpot.waitForLayoutUpdate(); + // A different family practically never yields the same measured width + // for a 54-char string at 36px. + expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5); + }); + + test('range font change is re-measured after awaiting', async (ctx) => { + // Same probe through the text-range pipeline instead of the shape-level + // font setter. + const t = autoWidthText( + ctx, + 'The quick brown fox jumps over the lazy dog 0123456789', + ); + await ctx.penpot.waitForLayoutUpdate(); + const w0 = t.width; + + const font = ctx.penpot.fonts.all.find( + (f) => f.fontId !== t.fontId && f.variants.length > 0, + ); + if (!font) throw new Error('no alternative font available'); + const range = t.getRange(0, t.characters.length); + range.fontFamily = font.fontFamily; + range.fontId = font.fontId; + await ctx.penpot.waitForLayoutUpdate(); + expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5); + }); + }); + + describe('Timeout', () => { + // Pure geometry mutations (resize & co.) are settled synchronously by the + // renderer, so right after them nothing is pending and a short-timeout + // wait legitimately resolves. The deterministic pending source is a font + // change: it marks the text pending until the font is fetched and the + // shape re-measured, which can never complete within 1ms. These tests hit + // the real backend for the font asset, hence skipIfMocked. + + test.skipIfMocked( + 'rejects with a timeout error while a font is loading', + async (ctx) => { + const t = autoWidthText(ctx, 'The quick brown fox'); + await ctx.penpot.waitForLayoutUpdate(); + + const font = unloadedFont(ctx, t, 0); + t.fontId = font.fontId; + t.fontFamily = font.fontFamily; + await expectReject(ctx.penpot.waitForLayoutUpdate(1), /timeout/i); + }, + ); + + test.skipIfMocked('shape-level wait also rejects on timeout', async (ctx) => { + const t = autoWidthText(ctx, 'The quick brown fox'); + await ctx.penpot.waitForLayoutUpdate(); + + const font = unloadedFont(ctx, t, 1); + t.fontId = font.fontId; + t.fontFamily = font.fontFamily; + await expectReject(t.waitForLayoutUpdate(1), /timeout/i); + }); + + test('resolves normally when work settles before the timeout', async (ctx) => { + const { rects } = flexRow(ctx); + await ctx.penpot.waitForLayoutUpdate(); + + const { left, right } = byX(rects); + left.resize(120, 50); + await ctx.penpot.waitForLayoutUpdate(5000); + expect(right.x - left.x).toBeCloseTo(130, 0); + }); + + test.skipIfMocked('a rejected wait leaves later waits working', async (ctx) => { + const t = autoWidthText(ctx, 'The quick brown fox'); + await ctx.penpot.waitForLayoutUpdate(); + const w0 = t.width; + + const font = unloadedFont(ctx, t, 2); + t.fontId = font.fontId; + t.fontFamily = font.fontFamily; + await expectReject(ctx.penpot.waitForLayoutUpdate(1), /timeout/i); + // A second wait with a generous timeout still tracks the same pending + // work and resolves once the font lands and the text is re-measured. + await ctx.penpot.waitForLayoutUpdate(12000); + expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5); + }); + + test.skipIfMocked( + 'per-shape wait is not blocked by another shape pending work', + async (ctx) => { + const t = autoWidthText(ctx, 'The quick brown fox'); + const bystander = ctx.penpot.createRectangle(); + ctx.board.appendChild(bystander); + await ctx.penpot.waitForLayoutUpdate(); + + const font = unloadedFont(ctx, t, 3); + t.fontId = font.fontId; + t.fontFamily = font.fontFamily; + // The text has pending font work but the bystander does not; its wait + // must resolve on the fast path instead of waiting for the font. + const started = Date.now(); + await bystander.waitForLayoutUpdate(); + expect(Date.now() - started).toBeLessThan(90); + }, + ); + }); +});