* ⚡ Keep the translation flag on undo and redo of a move
Previously undos and redos always forced a component sync + thumbnail
rerender for every component touched even if the underlying commits were just
translations. This propagates the translation flag to undos and redos and
propagates it to commit-changes. An entry merged from several undos/redos is
a translation only if all of its undos/redos are translations.
This reduces a UI freeze when undoing a move of a board with 453 components
from 2.42s -> 0.22s.
AI-assisted-by: claude-sonnet-5-5
* :translation? -> :translation
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* ⚡ Send objects-map pages to the worker without a second encode
When switching between pages on a large file e.g Material Design Icons, there's
some UI freeze from :index/initialize encoding object map shapes to send to the
worker. An objects map already stores the encoded JSON for each shape, and
`encode-str` was wrapping those strings in a second layer of JSON on the main
thread.
We can just transmit the already encoded JSON in a js/Map alongisde the
payload, similar to :blob, so structured clone just copies the strings verbaitm
without re-encoding. To do this this PR adds two functions to ObjectsMap that
converts to and from a js/Map.
The "Main components" page of the Material Design Icons has ~30k objects, so
this reduces the freeze when switching pages from 306ms -> 189ms (1.63x) on a
release build.
AI-assisted-by: claude-sonnet-5-5
* Split property tests out into new deftest
---------
Signed-off-by: Luke Lau <luke_lau@icloud.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Cache the transit reader when decoding strings
On the cljs side, we create a new transit reader on every call to decode-str.
This turns out to have a fair bit overhead in both transit-cljs and transit-js,
since it has to merge the handlers, call clj->js, allocate a new ReadCache,
etc.
By reusing it the measured decode time goes from ~58us -> ~41us, so about 1.4x
faster. This particularly helps for some operations like whenever a component
is edited and every object needs to be decoded, see
https://github.com/penpot/penpot/issues/9344, although they could probably be
made more efficient in other ways.
The reader usually clears its read cache after a successful read, but whenever
it throws it hangs around. So in this case we need to reset the cache
ourselves, otherwise the next read will be from a clobbered cache.
The tests were written by Claude Sonnet 5.5, and should cover the clobbering
behaviour above.
Signed-off-by: Luke Lau <luke@igalia.com>
* Keep one transit reader per type and rebuild it on add-handlers!
---------
Signed-off-by: Luke Lau <luke@igalia.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The client calls the duplicate-file command in two places, the dashboard and in
the plugin API. In both places the :data is dropped via `(dissoc file :data)`,
so the client never uses that field.
However the file data takes a while to send over the wire and decode. When
duplicating penpot-app there's a roughly 1s freeze whenever the response is
returned from the server with the file data. We can avoid it by dropping the
data from the response.
I also did a quick check and neither the MCP server nor any public client of
the API on GitHub seem to use the :data field.
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🔧 Bump Rust version of the devenv image to 1.99
* 🔧 Run the pinned Rust setup from the lint and format scripts
./build and ./test switch to the Rust version in render-wasm/_build_env
through setup, but ./lint did not, and formatting called cargo fmt
directly. In CI both ran on the devenv image's default toolchain.
Make ./lint run setup and add a ./fmt script that does the same, so all
render-wasm entry points use the pinned version. CI and scripts/ci now
call ./fmt instead of cargo fmt.
AI-assisted-by: claude-opus-5-5
* 🐛 Return the full profile from the LDAP invitation branch
The invitation branch responded with only {:invitation-token token},
so the session start received a map without id or auth-backend. With
the new auth-origin vocabulary that map fails validation and the login
breaks in development and tests; in production the event would be
emitted without a channel.
Return the profile plus the token, like the regular branch, and cover
both branches with RPC-level tests.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Mint an auth token in the OIDC callback with the invitation inside
The callback used the invitation JWE as the session token, so the
frontend landed directly on token verification and never ran the
regular session start: no signin event, no teams, no host hook.
Always mint an :auth token and carry the invitation in its claims.
Token verification returns the claims verbatim, so login-from-token
attaches the invitation to the profile and logged-in does the rest,
the same pattern the verify-email token already uses.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* ✨ Keep the session origin in the telemetry shadow rows
The anonymisation filter drops every string unless it is on a short
allowlist. Add source and activation next to lang and auth-backend so
the shadow copy keeps them; the email and the full name are still
dropped and the email domain is still derived from the email.
The filter keeps its two bindings: the transducer discards everything
that is not a UUID, a boolean or a number, so the allowlist is applied
on the filtered map, never on the raw props.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* ✨ Describe the signup outcome with activation, not with action
The three success branches of register-profile labelled a new account
as "login", "email-verification" or "accept-invitation", so the event
contradicted its own name and the value only lived in context, where
the analytics consumers never read it.
Add activation to the event props and drop action from those branches:
the name already says it is a signup, and a constant action would only
add noise. The retry branches keep their action in context because
there it distinguishes four different outcomes.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* ✨ Describe the session origin in the signin event
The event carried the profile but never said why the session started:
the only field that tried was frontend metadata that never leaves the
browser, and several flows filled it with the wrong value.
Add a small closed vocabulary (source, auth-backend, activation,
from-invitation) and compose it from the profile when signin is
emitted. The assertion fails in development and tests and is elided in
the release build, so a bad value cannot break a real login.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* ✨ Emit the session origin from every login entry point
The four emitters stamped a free-form string as metadata, so the
session event could not rely on it: two flows that were signups wrote
the same value as a login.
Route them through origin-meta: password and LDAP logins declare
source login, register declares source register with activation
immediate, and login-from-token derives both from the token issuer:
:auth for a login and :verify-email for a signup that needed the
email confirmation.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Send the invitation signups through the regular session start
Registering from a team invitation link navigated by hand to token
verification, so the session start never ran: no signin event, no
teams, no host hook. The same navigation was copied in four places.
Teach login-from-register to carry an optional invitation token,
extract the profile decoration into with-register-origin, and call it
from the four sites instead of the hand-written navigation.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Resolve the invitation without waiting for the teams
Registering from an invitation link now goes through the session
start, and that start only navigates after the teams request
completes. fetch-teams has no error handling, so a network failure
there would leave the invitation unaccepted and the user on the signup
screen with a session already minted.
Move the invitation navigation out of the teams path: it emits as soon
as the profile carries a token, and the teams path emits nothing for
an invited profile so it cannot move the user away from the token
verification screen.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* 📚 Document the session origin vocabulary and the invitation flow
Add a focused memory with the four fields, where each value comes from,
the table of flows, the LDAP exception and the invitation path through
logged-in, and link it from the frontend routing.
Update the audit-log memory: source and activation are now on the
telemetry allowlist, activation lives in props and action only exists
on the retry rows.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* 📎 Cover the invitation session start and fix the auth memories
The invitation flows and the token decoration in login-from-token were
only covered by helper tests, so removing the guard that keeps the
teams path from navigating, or the token attach, broke the behaviour
silently.
Run the events through ptk/watch with a teams-fetched event as input:
an invited profile navigates to the token verification screen and
never to the dashboard, and the signin keeps from-invitation. Add the
login-from-token cases with and without the token, and fix the memory
statements about action, the flow table and from-invitation.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
Ignore imported library relations that point outside the
package or self-link a file on the v3 import path. Delete
leftover cross-team and self relations from file_library_rel
and their matching file_library_sync rows with migration
0158, sync rows first.
Move the get-files-rels SQL to a var, without behaviour change.
Cover the write-side invariants with state-based tests:
tampered manifests, package containment, self links and the
migration cleanup.
AI-assisted-by: deepseek-v4.1-flash
AI-assisted-by: muse-spark-1.3-contributor
(cherry picked from commit 0b8a428d6b28c35601793f8d05570dae614a2fd5)
* ⚡ Compute text fonts once per shape when uploading to wasm
* ⚡ Write the shapes batch in place and coerce fills once
* 🐛 Free the upload buffer when a batch record write fails
New profiles now store renderer `wasm` in their props at creation, so
every signup path (email, OIDC, LDAP, Nitrate, demo, CLI) starts on
WebGL. A renderer passed by the caller still wins.
Existing profiles without a stored renderer keep the frontend fallback
to SVG, so no backfill or version check is needed to leave them as they
are.
The `register-profile` audit event gains `renderer-default` when the
profile is created in that call, so signups before and after this
change can be told apart.
Closes#11855
AI-assisted-by: claude-opus-5-5
A view URL without a page-id crashed builds with assertions enabled:
the viewer passes a nil page-id to `initialize`, whose schema accepted
a missing key but not a nil value. Release builds skip that check and
draw the first page, but every viewer event reads the page from the
route, so zoom to fit and fill, the next-board arrow, Navigate To
interactions, and comment pins did not work.
`initialize` now accepts a nil page-id, `fetch-bundle` makes it
optional, and `bundle-fetched` replaces a route without page-id by one
naming the file's first page. The viewer then initializes again on the
completed route, as it does when the user changes page. A file with no
pages keeps its route.
AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
* 🐛 Normalize stored SVG attribute keys to kebab-case
The v3 reader already emits kebab-case keys, but birth stored the
camelCase React prop names, so import and export never converged:
fill rules painted wrongly and filter defs lost their blur.
Birth now normalizes every stored attribute key to kebab-case
through the `svg-props` whitelist (`attrs->kebab-props`), applied on
all element paths of the svg importer, on the file builder and on
defs. svg-raw `:content` is kebab-ized in spelling only and keeps
unknown keys, because the sidebar reads them. Non-spec attrs Penpot
must keep live in `penpot-extra-attrs`, seeded with `:stroke-style`.
Render boundaries translate back to camelCase for React and for the
wasm props, and the shape schema declares the closed kebab contract
derived from that same whitelist, so birth and validation cannot
drift.
Migration `0035-fix-shape-svg-attrs-and-defs` converts old files
once with the birth transforms, and carries back the svg-viewbox
rect normalization the withdrawn `0021` had. That old id leaves
`available-migrations`, so files recording it migrate again through
this one, and the binfile cleaner drops its own svg repair.
Review fixes included here: `setup-opacity` and the blend mode read
the stored keys, gradient stops accept `:style` as a CSS string or a
map, `inherit-attributes` reads `:style` so a child's inline style
beats the inherited group attr per CSS, and the svg-attrs menu shows
spec spelling (`viewBox`, `class`) through `stored-attr-display-name`.
Tests cover the migration, the closed svg-attr schemas and the
import/export round trip; the svg-content schema test pins its
sample seed, which dealt no svg-raw shape on unlucky runs.
Closes#11947
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Keep legacy svg node tags and transforms valid in migration 0035
The closed svg node schemas require keyword tags and map attrs, but
stored files hold def and svg-raw content tags as strings, and some
node `:attrs` as nil. Migration 0035 left them as they were, so the
file data check that runs after migrating rejected those files and
they could not be opened. `normalize-node-tree` now turns string tags
into keywords and nil attrs into an empty map, at birth and in 0035.
With keyword tags, the svg defs renderer applies the shape transform
to user-space defs. Legacy `:svg-transform` values are plain maps,
which multiply into NaN matrices. `gmt/coerce` turns them into
matrices, in 0035 and in the binfile cleaner, because every v3 import
decodes the transform as a plain map again. Legacy masks and gradients
now render as designed in the svg renderer.
The binfile test helper calls `export-files!` with the current config
keys.
AI-assisted-by: claude-opus-5-5
---------
Ignore imported library relations that point outside the
package or self-link a file on the v3 import path. Delete
leftover cross-team and self relations from file_library_rel
and their matching file_library_sync rows with migration
0158, sync rows first.
Move the get-files-rels SQL to a var, without behaviour change.
Cover the write-side invariants with state-based tests:
tampered manifests, package containment, self links and the
migration cleanup.
AI-assisted-by: deepseek-v4.1-flash
AI-assisted-by: muse-spark-1.3-contributor
In a damaged file a shape may point to a parent that does not exist.
The Inspect tab computes the position relative to the parent, so
inspecting such a shape replaced the workspace with the internal error
page. Inspect leaves the position out and logs a warning instead.
A component whose :main-instance-id points to a missing shape fails
validation on its copies with :ref-shape-not-found. A repair scoped to
the copy's page never sees the main's own error, so it detached the
copy, even when the main was still on the component's page.
The repair of :ref-shape-not-found now looks for the component's main
on its page first and points :main-instance-id back to it, which keeps
the copy linked. A copy is still detached when no main is found.
A rollback reverts the local data with a rebase, which is not a commit,
so the parts of the workspace that follow commits missed it:
- Comment pins moved in the reverted action stayed where they were, on
screen and on the server, and pins on a board the rollback removed
kept pointing to it. They go back to the reverted position, and pins
on a removed board move to the page root.
- Focus mode stayed on with no shape left when the rollback removed the
focused ones, and the next shape drawn turned it on again. It ends
when its last shape goes, and the selection drops removed shapes.
- Component thumbnails kept the rejected look. The thumbnails of the
reverted mains are rendered and uploaded again, and an upload whose
shapes changed during the render is skipped.
- Under WASM, an open text editor reset to the reverted content but the
canvas kept the old layout. It is laid out again.
- A library sync whose save failed showed no update notice again once
the release undid it. The notice is checked again after that
rollback.
Before it repairs a damaged file, the backend keeps a copy of it as a
system snapshot, and the repair notice points to the version history to
find it. The History panel listed that copy among the autosaves, so it
could not be told apart.
The History panel lists the copies kept before a repair as their own
entry per day, "Before automatic repair, <date>", above that day's
autosaves, and the preview of such a copy uses the same label.
The notice of a rejected save always said the same thing. It did not
name the file when the user had moved to another one, gave no hint when
the page held data that was already invalid, and replaced the notice of
a backend repair still on screen, since the store holds one toast.
The backend flags a rejection whose first commit touches pages or
components that failed validation before it (:preexisting-damage). The
rejection notice names a file other than the open one, says when the
data was damaged before, and folds into a repair notice still on
screen; a later repair notice folds a rejection notice in the same way.
The notice says that the latest changes were undone, no longer that the
rest of the work is saved.
The answer to a save lists the changes other sessions stored before it,
such as the backend's repair of a damaged file. When the answer came
before the message with such a change, the saved commit was
acknowledged first, and the late change was then applied on top of the
local edits instead of under them, out of the server's order.
A saved commit of the open file now stays pending until this tab has
received every change the answer lists from other sessions, so a late
change is applied under the local edits. If one does not arrive within
10 seconds, the file reloads.
When a peer restores a version, the file reloads to it. Edits made on
the replaced version stayed queued: their save failed for good and
locked the workspace, and a rollback or a rebase replayed them on the
restored data.
Loading another version forgets the queued, buffered and saved commits
made on the old one. A save of such a commit, in flight or answered, is
dropped instead of failing or being rolled back, and one save carries
the edits of one version only.
While saves fail the workspace turns read-only, but only some tools
checked it. Edits from the asset, color and token panels, and a drag or
a drawing in progress when the lock came, still made commits that
queued behind the failed save.
commit-changes refuses commits while the save-failure lock holds,
except those that are not user edits: the undo of rolled-back commits,
text position data and component touches. Undo and redo do nothing
while locked. When the lock comes, moves, resizes, rotations, drawings
and the drags of flex, guide and grid handles in progress are cancelled
and commit nothing, and the design panel shows the Inspect view.
Reloading the open file (a peer's restore, the fallback of a rollback)
runs initialize-workspace again without leaving the workspace. The
streams the first run started kept running next to the new ones, so a
commit added one undo entry per reload and each notification was
handled twice. The old subscription also sent its unsubscribe after the
new subscribe, which ended the new one on the server.
A new initialize-workspace, text reflow, shape layout or notification
subscription stops the one before it, and a subscription replaced by
one to the same file sends no unsubscribe.
The MCP plugin sends the rollbacks it saw with its next task response
and then forgets them. When that response came after the server had
given up on the task, the server dropped it and the agent never heard
about the rollback. A reload of the tab or of the plugin lost them too.
The server now sends the rollbacks of such a late response back to the
plugin, which reports them again with its next response. The plugin
keeps the rollbacks not reported yet in its local storage, one entry
per file, so a reload does not lose them. Both MCP plugin manifests ask
for the allow:localstorage permission for that.
Plugin writes failed whenever the workspace read-only flag was set. The
Inspect tab sets it too, although it only hides the editing tools, so an
MCP agent could not edit while the user was inspecting. A user who
became a viewer had the flag cleared, so plugin writes did not throw and
silently did nothing.
Writes now fail on what makes them unsafe, not on the flag: failing
saves, a user who cannot edit, a version preview or a lost render
context. The error says which one, and the MCP instructions tell the
agent which of them pass by waiting.