✨ 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.
This commit is contained in:
Alejandro Alonso 2026-09-25 12:40:56 +02:00 committed by GitHub
parent 63baf86152
commit ec5a1edbed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 106 additions and 142 deletions

View File

@ -57,7 +57,6 @@
"text-editor/v2" "text-editor/v2"
"text-editor-wasm/v1" "text-editor-wasm/v1"
"render-wasm/v1" "render-wasm/v1"
"wasm-export/v1"
"variants/v1"}) "variants/v1"})
;; A set of features enabled by default ;; A set of features enabled by default
@ -83,8 +82,7 @@
"text-editor/v2" "text-editor/v2"
"text-editor-wasm/v1" "text-editor-wasm/v1"
"tokens/numeric-input" "tokens/numeric-input"
"render-wasm/v1" "render-wasm/v1"})
"wasm-export/v1"})
;; Features that are mainly backend only or there are a proper ;; Features that are mainly backend only or there are a proper
;; fallback when frontend reports no support for it ;; fallback when frontend reports no support for it

View File

@ -184,9 +184,6 @@
:stroke-path :stroke-path
:stroke-per-side :stroke-per-side
;; Exporter only: uses render-wasm for export instead of browser
;; renderer.
:wasm-export
:custom-shortcuts :custom-shortcuts
:remote-media-processing :remote-media-processing

View File

@ -34,11 +34,10 @@
:public-uri (str (cf/get :public-uri)) :public-uri (str (cf/get :public-uri))
:internal-uri (str (cf/get-internal-uri)) :internal-uri (str (cf/get-internal-uri))
:version (:full cf/version)) :version (:full cf/version))
(when (contains? cf/flags :wasm-export) (l/info :msg "headless wasm export available"
(l/info :msg "headless wasm export enabled (experimental)" :wasm-dir wasm/artifact-dir
:wasm-dir wasm/artifact-dir :workers (cf/get :wasm-worker-pool-max)
:workers (cf/get :wasm-worker-pool-max) :image-cache-size (cf/get :wasm-worker-image-cache-size))
:image-cache-size (cf/get :wasm-worker-image-cache-size)))
(p/do (p/do
(bwr/init) (bwr/init)
(redis/init) (redis/init)

View File

@ -71,11 +71,8 @@
(let [{:keys [resource total headless run]} (prepare cmd auth-token params)] (let [{:keys [resource total headless run]} (prepare cmd auth-token params)]
(->> (jobs/create! {:profile-id profile-id (->> (jobs/create! {:profile-id profile-id
:cmd cmd :cmd cmd
;; What the renderer will actually do, not what the ;; Backend used for admission and cancel UI: headless
;; client asked for: `is-wasm` alone still renders in ;; WASM when `:is-wasm` is set, otherwise browser.
;; 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 (if headless "wasm" "browser") :backend (if headless "wasm" "browser")
:total total :total total
:name (:name resource) :name (:name resource)

View File

@ -164,9 +164,8 @@
(defn prepare-exports (defn prepare-exports
[exports token is-wasm] [exports token is-wasm]
(letfn [(process-group [[part1 :as group]] (letfn [(process-group [[part1 :as group]]
;; The browser renders a partition as a single DOM page, so it is ;; Browser path renders one DOM page per partition; headless WASM
;; chunked to bound that page. A wasm export is headless, so ;; takes the whole group in one render.
;; it does not need to be chunked, and can be rendered as a single partition.
(if (rd/headless? {:is-wasm is-wasm :type (:type part1)}) (if (rd/headless? {:is-wasm is-wasm :type (:type part1)})
[(build-render group)] [(build-render group)]
(sequence (comp (partition-all default-partition-size) (sequence (comp (partition-all default-partition-size)

View File

@ -9,7 +9,6 @@
(:require (:require
[app.common.logging :as l] [app.common.logging :as l]
[app.common.spec :as us] [app.common.spec :as us]
[app.config :as cf]
[app.renderer.bitmap :as rb] [app.renderer.bitmap :as rb]
[app.renderer.pdf :as rp] [app.renderer.pdf :as rp]
[app.renderer.svg :as rs] [app.renderer.svg :as rs]
@ -42,7 +41,7 @@
(defn headless? (defn headless?
"Whether `params` renders with render-wasm rather than a browser." "Whether `params` renders with render-wasm rather than a browser."
[{:keys [is-wasm]}] [{:keys [is-wasm]}]
(and is-wasm (contains? cf/flags :wasm-export))) (boolean is-wasm))
(defn render (defn render
[{:keys [type is-wasm] :as params} on-object] [{:keys [type is-wasm] :as params} on-object]
@ -52,7 +51,6 @@
(when is-wasm (when is-wasm
(l/info :hint "render" (l/info :hint "render"
:type type :type type
:wasm-export (contains? cf/flags :wasm-export)
:backend (if headless? "wasm" "browser"))) :backend (if headless? "wasm" "browser")))
(if headless? (if headless?
(rw/render params on-object) (rw/render params on-object)

View File

@ -16,9 +16,8 @@
the idle watchdog guarantees a wedged worker gives its slot back. 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 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; `isMainThread`. There is always at least one worker, since a headless render
with it there is always at least one, since a headless render has nowhere has nowhere else to go."
else to go."
(:require (:require
["generic-pool" :as gp] ["generic-pool" :as gp]
["node:path" :as path] ["node:path" :as path]
@ -104,32 +103,27 @@
(defn capacity (defn capacity
"How many renders can run at once, and so how many headless jobs the "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 scheduler may admit."
when no job is headless, so a headless job always has a worker to wait for."
[] []
(if (contains? cf/flags :wasm-export) ;; Clamped rather than rejected: a bad value should not stop the exporter
;; 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.
;; from booting, and a headless render has no other backend to fall back to. (max 1 (cf/get :wasm-worker-pool-max 2)))
(max 1 (cf/get :wasm-worker-pool-max 2))
0))
(defn init (defn init
[] []
(let [configured (cf/get :wasm-worker-pool-max 2) (let [configured (cf/get :wasm-worker-pool-max 2)
max-workers (capacity)] max-workers (capacity)
(when (and (pos? max-workers) (not= configured max-workers)) 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" (l/warn :hint "wasm-worker-pool-max raised to the minimum of one"
:configured configured)) :configured configured))
(if (pos? max-workers) (l/info :hint "initializing render worker pool" :opts opts)
(let [opts #js {:max max-workers (reset! pool (gp/createPool worker-pool-factory opts))
: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"))
(p/resolved nil))) (p/resolved nil)))
(defn stop (defn stop

View File

@ -511,44 +511,26 @@ test("BUG 12384 - Export crashing when exporting a board", async ({ page }) => {
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockRPC(/get\-file\?/, "design/get-file-12384.json"); 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) => { await workspace.page.route("**/api/export", (route) => {
if (hasExportRequestBeenIntercepted) { throw new Error("unexpected /api/export request under render-wasm");
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: {},
});
}); });
await workspace.goToWorkspace({ await workspace.goToWorkspace({
fileId: "fa6ce865-34dd-80ac-8006-fe0dab5539a7", fileId: "fa6ce865-34dd-80ac-8006-fe0dab5539a7",
pageId: "fa6ce865-34dd-80ac-8006-fe0dab5539a8", pageId: "fa6ce865-34dd-80ac-8006-fe0dab5539a8",
}); });
await workspace.waitForFirstRender();
await workspace.clickLeafLayer("Board"); 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 expect(download.suggestedFilename()).toBe("Board.png");
.getByRole("button", { name: "Export 1 element" })
.click();
await exportRequest;
}); });

View File

@ -8,7 +8,6 @@
(:require (:require
[app.common.time :as ct] [app.common.time :as ct]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.event :as ev] [app.main.data.event :as ev]
[app.main.data.exports.wasm :as wasm.exports] [app.main.data.exports.wasm :as wasm.exports]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
@ -255,9 +254,9 @@
(def ^:private wasm-export-types #{:jpeg :webp :png :pdf :svg}) (def ^:private wasm-export-types #{:jpeg :webp :png :pdf :svg})
(defn- wasm-export-enabled? (defn- wasm-export-enabled?
"True when the active renderer is render-wasm."
[state] [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? (defn- use-wasm-export?
"Whether to take the client-side WASM export path for `export`." "Whether to take the client-side WASM export path for `export`."

View File

@ -1170,8 +1170,7 @@
(rx/of (ntf/info (tr "workspace.clipboard.copying"))) (rx/of (ntf/info (tr "workspace.clipboard.copying")))
;; Call exporter to get image URI, then fetch blob and resolve the deferred. ;; Call exporter to get image URI, then fetch blob and resolve the deferred.
(->> (if (and (features/active-feature? state "render-wasm/v1") (->> (if (features/active-feature? state "render-wasm/v1")
(contains? cf/flags :wasm-export))
(rx/of {:uri (wasm.exports/export-image-uri export)}) (rx/of {:uri (wasm.exports/export-image-uri export)})
(rp/cmd! :export (rp/cmd! :export
{:exports [export] {:exports [export]

View File

@ -33,7 +33,6 @@
[app.common.types.shape.shadow :as ctss] [app.common.types.shape.shadow :as ctss]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.exports.assets :as de] [app.main.data.exports.assets :as de]
[app.main.data.exports.wasm :as wasm.exports] [app.main.data.exports.wasm :as wasm.exports]
[app.main.data.persistence :as dwp] [app.main.data.persistence :as dwp]
@ -49,6 +48,7 @@
[app.main.data.workspace.texts :as dwt] [app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.variants :as dwv] [app.main.data.workspace.variants :as dwv]
[app.main.features :as features]
[app.main.repo :as rp] [app.main.repo :as rp]
[app.main.store :as st] [app.main.store :as st]
[app.plugins.exports :as exports] [app.plugins.exports :as exports]
@ -1603,65 +1603,67 @@
(u/not-valid plugin-id :export "Plugin doesn't have 'content:read' permission") (u/not-valid plugin-id :export "Plugin doesn't have 'content:read' permission")
:else :else
(if (and (contains? cf/flags :wasm-export) (let [export-type (:type value :png)
(contains? #{:jpeg :webp :png} (:type value :png))) wasm-enabled? (features/active-feature? @st/state "render-wasm/v1")]
;; New export with wasm (if (and wasm-enabled?
(let [uri (wasm.exports/export-image-uri (contains? #{:jpeg :webp :png :pdf :svg} export-type))
{:file-id file-id ;; Render in the browser with render-wasm.
:page-id page-id (let [export {:file-id file-id
:object-id id :page-id page-id
:type (:type value :png) :object-id id
:scale (:scale value 1)})] :type export-type
(js/Promise. :scale (:scale value 1)}
(fn [resolve reject] uri (case export-type
(->> (http/send! :pdf (wasm.exports/export-pdf-uri export)
{:method :get :svg (wasm.exports/export-svg-uri export)
:uri uri (wasm.exports/export-image-uri export))]
:response-type :blob (js/Promise.
:omit-default-headers true}) (fn [resolve reject]
(rx/map :body) (->> (http/send!
(rx/mapcat #(.arrayBuffer %)) {:method :get
(rx/map #(js/Uint8Array. %)) :uri uri
(rx/tap #(st/emit! (se/event plugin-id "export-shapes" :method "wasm"))) :response-type :blob
(rx/subs! resolve reject))))) :omit-default-headers true})
(rx/map :body)
;; Old export through exporter (rx/mapcat #(.arrayBuffer %))
(let [shape (u/locate-shape file-id page-id id) (rx/map #(js/Uint8Array. %))
payload (rx/tap #(st/emit! (se/event plugin-id "export-shapes" :method "wasm")))
{:cmd :export-shapes (rx/subs! resolve reject)))))
: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)))))))))
;; 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 ;; Interactions
:addInteraction :addInteraction

View File

@ -78,11 +78,11 @@ flowchart TB
## Export wiring (single vs multiple) ## Export wiring (single vs multiple)
The client-side WASM export — rendering in the browser through the vector path The client-side WASM export — rendering in the browser through the vector path
(`render_shape_pdf` / `render_shape_pixels`) — is wired **only for single (`render_shape_pdf` / `render_shape_pixels` / `render_shape_svg`) — is wired
exports** (`request-simple-export` in `frontend/.../exports/assets.cljs`), and **only for single exports** (`request-simple-export` in
only when render-wasm is active and the `enable-wasm-export` flag is set. The `frontend/.../exports/assets.cljs`), and only when the active renderer is
exporter service reads the same flag to decide whether it can serve the render-wasm (`render-wasm/v1`). The exporter service takes the headless WASM
headless WASM path. path when the client sends `:is-wasm true` (same condition).
**Multiple/batch export** (`request-multiple-export`) always runs **server-side** **Multiple/batch export** (`request-multiple-export`) always runs **server-side**
via the `:export-shapes` command; it merely passes an `:is-wasm` hint so the via the `:export-shapes` command; it merely passes an `:is-wasm` hint so the