From ec5a1edbed25cdb9152e13bb04dcf510a8b3d40c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 25 Sep 2026 12:40:56 +0200 Subject: [PATCH] :sparkles: Make export follow the active renderer only (#11910) Drop the separate :wasm-export flag and wasm-export/v1 feature. Single export, clipboard PNG, plugins, and batch :is-wasm now key off render-wasm/v1 alone. The exporter trusts :is-wasm for headless WASM and always keeps a worker pool ready. --- common/src/app/common/features.cljc | 4 +- common/src/app/common/flags.cljc | 3 - exporter/src/app/core.cljs | 9 +- exporter/src/app/handlers/export.cljs | 7 +- exporter/src/app/handlers/export_shapes.cljs | 5 +- exporter/src/app/renderer.cljs | 4 +- exporter/src/app/wasm/pool.cljs | 38 +++--- .../playwright/ui/specs/design-tab.spec.js | 40 ++---- .../src/app/main/data/exports/assets.cljs | 5 +- .../app/main/data/workspace/clipboard.cljs | 3 +- frontend/src/app/plugins/shape.cljs | 120 +++++++++--------- render-wasm/docs/rendering_architecture.md | 10 +- 12 files changed, 106 insertions(+), 142 deletions(-) diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index e1b12729e0..c29b99aee3 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -57,7 +57,6 @@ "text-editor/v2" "text-editor-wasm/v1" "render-wasm/v1" - "wasm-export/v1" "variants/v1"}) ;; A set of features enabled by default @@ -83,8 +82,7 @@ "text-editor/v2" "text-editor-wasm/v1" "tokens/numeric-input" - "render-wasm/v1" - "wasm-export/v1"}) + "render-wasm/v1"}) ;; Features that are mainly backend only or there are a proper ;; fallback when frontend reports no support for it diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 89a0b31474..bf38707fa4 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -184,9 +184,6 @@ :stroke-path :stroke-per-side - ;; Exporter only: uses render-wasm for export instead of browser - ;; renderer. - :wasm-export :custom-shortcuts :remote-media-processing diff --git a/exporter/src/app/core.cljs b/exporter/src/app/core.cljs index ec04fa7685..a1ddab7efa 100644 --- a/exporter/src/app/core.cljs +++ b/exporter/src/app/core.cljs @@ -34,11 +34,10 @@ :public-uri (str (cf/get :public-uri)) :internal-uri (str (cf/get-internal-uri)) :version (:full cf/version)) - (when (contains? cf/flags :wasm-export) - (l/info :msg "headless wasm export enabled (experimental)" - :wasm-dir wasm/artifact-dir - :workers (cf/get :wasm-worker-pool-max) - :image-cache-size (cf/get :wasm-worker-image-cache-size))) + (l/info :msg "headless wasm export available" + :wasm-dir wasm/artifact-dir + :workers (cf/get :wasm-worker-pool-max) + :image-cache-size (cf/get :wasm-worker-image-cache-size)) (p/do (bwr/init) (redis/init) diff --git a/exporter/src/app/handlers/export.cljs b/exporter/src/app/handlers/export.cljs index 65f5a7bcd6..8ba9a9c322 100644 --- a/exporter/src/app/handlers/export.cljs +++ b/exporter/src/app/handlers/export.cljs @@ -71,11 +71,8 @@ (let [{:keys [resource total headless run]} (prepare cmd auth-token params)] (->> (jobs/create! {:profile-id profile-id :cmd cmd - ;; What the renderer will actually do, not what the - ;; client asked for: `is-wasm` alone still renders in - ;; the browser without the `wasm-export` flag, or for - ;; svg, and the backend decides both the admission cap - ;; and whether the client offers to cancel. + ;; Backend used for admission and cancel UI: headless + ;; WASM when `:is-wasm` is set, otherwise browser. :backend (if headless "wasm" "browser") :total total :name (:name resource) diff --git a/exporter/src/app/handlers/export_shapes.cljs b/exporter/src/app/handlers/export_shapes.cljs index 954285ba1e..27f5844e98 100644 --- a/exporter/src/app/handlers/export_shapes.cljs +++ b/exporter/src/app/handlers/export_shapes.cljs @@ -164,9 +164,8 @@ (defn prepare-exports [exports token is-wasm] (letfn [(process-group [[part1 :as group]] - ;; The browser renders a partition as a single DOM page, so it is - ;; chunked to bound that page. A wasm export is headless, so - ;; it does not need to be chunked, and can be rendered as a single partition. + ;; Browser path renders one DOM page per partition; headless WASM + ;; takes the whole group in one render. (if (rd/headless? {:is-wasm is-wasm :type (:type part1)}) [(build-render group)] (sequence (comp (partition-all default-partition-size) diff --git a/exporter/src/app/renderer.cljs b/exporter/src/app/renderer.cljs index 320ffa8060..98f97f3443 100644 --- a/exporter/src/app/renderer.cljs +++ b/exporter/src/app/renderer.cljs @@ -9,7 +9,6 @@ (:require [app.common.logging :as l] [app.common.spec :as us] - [app.config :as cf] [app.renderer.bitmap :as rb] [app.renderer.pdf :as rp] [app.renderer.svg :as rs] @@ -42,7 +41,7 @@ (defn headless? "Whether `params` renders with render-wasm rather than a browser." [{:keys [is-wasm]}] - (and is-wasm (contains? cf/flags :wasm-export))) + (boolean is-wasm)) (defn render [{:keys [type is-wasm] :as params} on-object] @@ -52,7 +51,6 @@ (when is-wasm (l/info :hint "render" :type type - :wasm-export (contains? cf/flags :wasm-export) :backend (if headless? "wasm" "browser"))) (if headless? (rw/render params on-object) diff --git a/exporter/src/app/wasm/pool.cljs b/exporter/src/app/wasm/pool.cljs index e9e8ded681..31a20527e7 100644 --- a/exporter/src/app/wasm/pool.cljs +++ b/exporter/src/app/wasm/pool.cljs @@ -16,9 +16,8 @@ the idle watchdog guarantees a wedged worker gives its slot back. Workers run the same bundle as the main thread; `app.core/start` branches on - `isMainThread`. Without the `wasm-export` flag no worker is spawned at all; - with it there is always at least one, since a headless render has nowhere - else to go." + `isMainThread`. There is always at least one worker, since a headless render + has nowhere else to go." (:require ["generic-pool" :as gp] ["node:path" :as path] @@ -104,32 +103,27 @@ (defn capacity "How many renders can run at once, and so how many headless jobs the - scheduler may admit. Zero exactly when headless export is off, which is also - when no job is headless, so a headless job always has a worker to wait for." + scheduler may admit." [] - (if (contains? cf/flags :wasm-export) - ;; Clamped rather than rejected: a bad value should not stop the exporter - ;; from booting, and a headless render has no other backend to fall back to. - (max 1 (cf/get :wasm-worker-pool-max 2)) - 0)) + ;; Clamped rather than rejected: a bad value should not stop the exporter + ;; from booting, and a headless render has no other backend to fall back to. + (max 1 (cf/get :wasm-worker-pool-max 2))) (defn init [] (let [configured (cf/get :wasm-worker-pool-max 2) - max-workers (capacity)] - (when (and (pos? max-workers) (not= configured max-workers)) + max-workers (capacity) + opts #js {:max max-workers + :min (min max-workers (cf/get :wasm-worker-pool-min 1)) + :testOnBorrow true + :evictionRunIntervalMillis 30000 + :numTestsPerEvictionRun 2 + :idleTimeoutMillis 300000}] + (when (not= configured max-workers) (l/warn :hint "wasm-worker-pool-max raised to the minimum of one" :configured configured)) - (if (pos? max-workers) - (let [opts #js {:max max-workers - :min (min max-workers (cf/get :wasm-worker-pool-min 1)) - :testOnBorrow true - :evictionRunIntervalMillis 30000 - :numTestsPerEvictionRun 2 - :idleTimeoutMillis 300000}] - (l/info :hint "initializing render worker pool" :opts opts) - (reset! pool (gp/createPool worker-pool-factory opts))) - (l/info :hint "render worker pool disabled, wasm export is off")) + (l/info :hint "initializing render worker pool" :opts opts) + (reset! pool (gp/createPool worker-pool-factory opts)) (p/resolved nil))) (defn stop diff --git a/frontend/playwright/ui/specs/design-tab.spec.js b/frontend/playwright/ui/specs/design-tab.spec.js index 9c156f8d64..ba2e53a5c4 100644 --- a/frontend/playwright/ui/specs/design-tab.spec.js +++ b/frontend/playwright/ui/specs/design-tab.spec.js @@ -511,44 +511,26 @@ test("BUG 12384 - Export crashing when exporting a board", async ({ page }) => { await workspace.setupEmptyFile(); await workspace.mockRPC(/get\-file\?/, "design/get-file-12384.json"); - let hasExportRequestBeenIntercepted = false; + // Single export with render-wasm runs in the browser; it must not hit the + // exporter API. Fail the test if that path is taken by mistake. await workspace.page.route("**/api/export", (route) => { - if (hasExportRequestBeenIntercepted) { - route.continue(); - return; - } - - hasExportRequestBeenIntercepted = true; - const payload = route.request().postData(); - const parsedPayload = JSON.parse(payload); - - expect(parsedPayload["~:exports"]).toHaveLength(1); - expect(parsedPayload["~:exports"][0]["~:file-id"]).toBe( - "~ufa6ce865-34dd-80ac-8006-fe0dab5539a7", - ); - expect(parsedPayload["~:exports"][0]["~:page-id"]).toBe( - "~ufa6ce865-34dd-80ac-8006-fe0dab5539a8", - ); - - route.fulfill({ - status: 200, - contentType: "application/json", - response: {}, - }); + throw new Error("unexpected /api/export request under render-wasm"); }); await workspace.goToWorkspace({ fileId: "fa6ce865-34dd-80ac-8006-fe0dab5539a7", pageId: "fa6ce865-34dd-80ac-8006-fe0dab5539a8", }); + await workspace.waitForFirstRender(); await workspace.clickLeafLayer("Board"); - let exportRequest = workspace.page.waitForRequest("**/api/export"); + const [download] = await Promise.all([ + workspace.page.waitForEvent("download"), + workspace.rightSidebar + .getByRole("button", { name: "Export 1 element" }) + .click(), + ]); - await workspace.rightSidebar - .getByRole("button", { name: "Export 1 element" }) - .click(); - - await exportRequest; + expect(download.suggestedFilename()).toBe("Board.png"); }); diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index f351746b49..e1f8f8b508 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -8,7 +8,6 @@ (:require [app.common.time :as ct] [app.common.uuid :as uuid] - [app.config :as cf] [app.main.data.event :as ev] [app.main.data.exports.wasm :as wasm.exports] [app.main.data.helpers :as dsh] @@ -255,9 +254,9 @@ (def ^:private wasm-export-types #{:jpeg :webp :png :pdf :svg}) (defn- wasm-export-enabled? + "True when the active renderer is render-wasm." [state] - (and (contains? cf/flags :wasm-export) - (features/active-feature? state "render-wasm/v1"))) + (features/active-feature? state "render-wasm/v1")) (defn- use-wasm-export? "Whether to take the client-side WASM export path for `export`." diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 002a1596b5..42ad15b662 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -1170,8 +1170,7 @@ (rx/of (ntf/info (tr "workspace.clipboard.copying"))) ;; Call exporter to get image URI, then fetch blob and resolve the deferred. - (->> (if (and (features/active-feature? state "render-wasm/v1") - (contains? cf/flags :wasm-export)) + (->> (if (features/active-feature? state "render-wasm/v1") (rx/of {:uri (wasm.exports/export-image-uri export)}) (rp/cmd! :export {:exports [export] diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 522788620a..4952a89c54 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -33,7 +33,6 @@ [app.common.types.shape.shadow :as ctss] [app.common.types.text :as txt] [app.common.uuid :as uuid] - [app.config :as cf] [app.main.data.exports.assets :as de] [app.main.data.exports.wasm :as wasm.exports] [app.main.data.persistence :as dwp] @@ -49,6 +48,7 @@ [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.variants :as dwv] + [app.main.features :as features] [app.main.repo :as rp] [app.main.store :as st] [app.plugins.exports :as exports] @@ -1603,65 +1603,67 @@ (u/not-valid plugin-id :export "Plugin doesn't have 'content:read' permission") :else - (if (and (contains? cf/flags :wasm-export) - (contains? #{:jpeg :webp :png} (:type value :png))) - ;; New export with wasm - (let [uri (wasm.exports/export-image-uri - {:file-id file-id - :page-id page-id - :object-id id - :type (:type value :png) - :scale (:scale value 1)})] - (js/Promise. - (fn [resolve reject] - (->> (http/send! - {:method :get - :uri uri - :response-type :blob - :omit-default-headers true}) - (rx/map :body) - (rx/mapcat #(.arrayBuffer %)) - (rx/map #(js/Uint8Array. %)) - (rx/tap #(st/emit! (se/event plugin-id "export-shapes" :method "wasm"))) - (rx/subs! resolve reject))))) - - ;; Old export through exporter - (let [shape (u/locate-shape file-id page-id id) - payload - {:cmd :export-shapes - :profile-id (:profile-id @st/state) - :wait true - :is-wasm false - :exports [(de/normalize-export {:file-id file-id - :page-id page-id - :object-id id - :name (:name shape) - :type (:type value :png) - :suffix (:suffix value "") - :scale (:scale value 1)})]}] - (js/Promise. - (fn [resolve reject] - ;; The exporter renders the file from its persisted - ;; state, so flush pending local changes and wait until - ;; they are saved before invoking it. Otherwise it may - ;; export a stale/empty shape. (The wasm export above - ;; renders locally and does not need this.) - (st/emit! ::dwp/force-persist) - (->> (rx/concat - (->> (dwp/wait-persisted 5000) - (rx/ignore)) - (rp/cmd! :export payload)) - (rx/mapcat (fn [{:keys [uri]}] - (->> (http/send! {:method :get - :uri uri - :response-type :blob - :omit-default-headers true}) - (rx/map :body)))) - (rx/mapcat #(.arrayBuffer %)) - (rx/map #(js/Uint8Array. %)) - (rx/tap #(st/emit! (se/event plugin-id "export-shapes" :method "exporter"))) - (rx/subs! resolve reject))))))))) + (let [export-type (:type value :png) + wasm-enabled? (features/active-feature? @st/state "render-wasm/v1")] + (if (and wasm-enabled? + (contains? #{:jpeg :webp :png :pdf :svg} export-type)) + ;; Render in the browser with render-wasm. + (let [export {:file-id file-id + :page-id page-id + :object-id id + :type export-type + :scale (:scale value 1)} + uri (case export-type + :pdf (wasm.exports/export-pdf-uri export) + :svg (wasm.exports/export-svg-uri export) + (wasm.exports/export-image-uri export))] + (js/Promise. + (fn [resolve reject] + (->> (http/send! + {:method :get + :uri uri + :response-type :blob + :omit-default-headers true}) + (rx/map :body) + (rx/mapcat #(.arrayBuffer %)) + (rx/map #(js/Uint8Array. %)) + (rx/tap #(st/emit! (se/event plugin-id "export-shapes" :method "wasm"))) + (rx/subs! resolve reject))))) + ;; Render via the exporter service. + (let [shape (u/locate-shape file-id page-id id) + payload + {:cmd :export-shapes + :profile-id (:profile-id @st/state) + :wait true + :is-wasm wasm-enabled? + :exports [(de/normalize-export {:file-id file-id + :page-id page-id + :object-id id + :name (:name shape) + :type export-type + :suffix (:suffix value "") + :scale (:scale value 1)})]}] + (js/Promise. + (fn [resolve reject] + ;; Exporter reads persisted file state; flush first + ;; so the export is not stale. Browser WASM above + ;; renders from local state and needs no flush. + (st/emit! ::dwp/force-persist) + (->> (rx/concat + (->> (dwp/wait-persisted 5000) + (rx/ignore)) + (rp/cmd! :export payload)) + (rx/mapcat (fn [{:keys [uri]}] + (->> (http/send! {:method :get + :uri uri + :response-type :blob + :omit-default-headers true}) + (rx/map :body)))) + (rx/mapcat #(.arrayBuffer %)) + (rx/map #(js/Uint8Array. %)) + (rx/tap #(st/emit! (se/event plugin-id "export-shapes" :method "exporter"))) + (rx/subs! resolve reject)))))))))) ;; Interactions :addInteraction diff --git a/render-wasm/docs/rendering_architecture.md b/render-wasm/docs/rendering_architecture.md index 9a6a090609..8d4557acd5 100644 --- a/render-wasm/docs/rendering_architecture.md +++ b/render-wasm/docs/rendering_architecture.md @@ -78,11 +78,11 @@ flowchart TB ## Export wiring (single vs multiple) The client-side WASM export — rendering in the browser through the vector path -(`render_shape_pdf` / `render_shape_pixels`) — is wired **only for single -exports** (`request-simple-export` in `frontend/.../exports/assets.cljs`), and -only when render-wasm is active and the `enable-wasm-export` flag is set. The -exporter service reads the same flag to decide whether it can serve the -headless WASM path. +(`render_shape_pdf` / `render_shape_pixels` / `render_shape_svg`) — is wired +**only for single exports** (`request-simple-export` in +`frontend/.../exports/assets.cljs`), and only when the active renderer is +render-wasm (`render-wasm/v1`). The exporter service takes the headless WASM +path when the client sends `:is-wasm true` (same condition). **Multiple/batch export** (`request-multiple-export`) always runs **server-side** via the `:export-shapes` command; it merely passes an `:is-wasm` hint so the