✨ 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-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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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");
});

View File

@ -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`."

View File

@ -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]

View File

@ -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

View File

@ -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