🐛 Fix plugin component lookups for missing and deleted components (#12755)

* 🐛 Fix plugin component lookups for missing and deleted components

Shape.component() threw "Assert failed: (uuid? id)" on a copy whose
library is not available, because the head lookup returns a pair with a
nil component and the proxy was built anyway. It now returns null.

Shape.componentRoot() built its proxy with the component's file and main
page, so a copy on another page or file than its main got a shape that
pointed at nothing, and a copy of a missing library threw. It now
resolves the root on the shape's own file and page, like componentHead().

LibraryComponent.mainInstance() returned a proxy for the root kept in a
deleted component's objects, which is on no page, so every field read
empty and parent threw. It now returns null for a deleted component.

* 📚 Fix the variant example in the MCP instructions

The createVariantContainer example called penpot.currentPage.findAllShapes
and Shape.isMainComponent, which do not exist, so running it as written
failed. Use penpotUtils.findShapes with isComponentMainInstance instead.
This commit is contained in:
Alonso Torres 2026-11-11 09:39:04 +01:00 committed by GitHub
parent 34c702bc80
commit f89879cca1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 70 additions and 16 deletions

View File

@ -1022,7 +1022,8 @@
(let [file (u/locate-file file-id)
component (u/locate-library-component file-id id)
root (ctf/get-component-root (:data file) component)]
(when (some? root)
;; A deleted component keeps its root only in its own objects, not on a page
(when (and (some? root) (not (:deleted component)))
(shape/shape-proxy plugin-id file-id (:main-instance-page component) (:id root)))))
:isVariant

View File

@ -1535,8 +1535,8 @@
(let [objects (u/locate-objects file-id page-id)
shape (u/locate-shape file-id page-id id)]
(when (ctn/in-any-component? objects shape)
(let [[root component] (u/locate-component objects shape)]
(shape-proxy plugin-id (:component-file root) (:main-instance-page component) (:id root))))))
(let [root (ctn/get-instance-root objects shape)]
(shape-proxy plugin-id file-id page-id (:id root))))))
:componentHead
(fn []
@ -1551,8 +1551,10 @@
(let [objects (u/locate-objects file-id page-id)
shape (u/locate-shape file-id page-id id)]
(when (ctn/in-any-component? objects shape)
(when-let [[head component] (u/locate-head-component objects shape)]
(lib-component-proxy plugin-id (:component-file head) (:id component))))))
(let [[head component] (u/locate-head-component objects shape)]
;; The component is nil when its library is not available
(when (some? component)
(lib-component-proxy plugin-id (:component-file head) (:id component)))))))
:detach
(fn []

View File

@ -203,6 +203,46 @@
(await (async/settle))
(t/is (empty? @events))))))))
(t/deftest ^:async main-instance-of-a-deleted-component-is-null
(let [file (file-with-deleted-component)
store (ths/setup-store file {:renderer :svg})]
(await
(mock/with-mocks*
{st/state store
shape/lib-component-proxy library/lib-component-proxy}
(let [copy (shape/shape-proxy plugin-id (:id file) (thi/id :main-page) (thi/id :copy))
component (.component copy)]
(t/is (some? component))
(t/is (nil? (.mainInstance component))))))))
(t/deftest ^:async component-root-of-a-copy-on-another-page-than-its-main
(let [file (-> (cthf/sample-file :file :page-label :main-page)
(cths/add-sample-shape :main :type :frame)
(cths/add-sample-shape :child :parent-label :main)
(cthc/make-component :component :main)
(cthf/add-sample-page :other-page)
(cthc/instantiate-component :component :copy :children-labels [:copy-child]))
store (ths/setup-store file {:renderer :svg})]
(await
(mock/with-mocks*
{st/state store}
(let [copy-child (shape/shape-proxy plugin-id (:id file) (thi/id :other-page) (thi/id :copy-child))
root (.componentRoot copy-child)]
(t/is (= (str (thi/id :copy)) (.-id root)))
(t/is (= (str (thi/id :copy)) (.-id (.componentHead copy-child)))))))))
(t/deftest ^:async component-of-a-copy-whose-library-is-missing-is-null
(let [file (-> (file-with-component)
(cths/update-shape :copy :component-file (uuid/next)))
store (ths/setup-store file {:renderer :svg})]
(await
(mock/with-mocks*
{st/state store
shape/lib-component-proxy library/lib-component-proxy}
(let [copy (shape/shape-proxy plugin-id (:id file) (thi/id :main-page) (thi/id :copy))]
(t/is (nil? (.component copy)))
(t/is (= (str (thi/id :copy)) (.-id (.componentRoot copy)))))))))
(t/deftest library-asset-proxies-expose-library-id
(let [file-id (random-uuid)
id (random-uuid)]

View File

@ -539,7 +539,7 @@ export class PenpotUtils {
* @example
* // Three button variants differing in Size
* // Given three main components s, m, l (here: the first three main components on the page)
* const [s, m, l] = penpot.currentPage.findAllShapes(sh => sh.isMainComponent()).slice(0, 3) as Board[];
* const [s, m, l] = PenpotUtils.findShapes(sh => sh.isComponentMainInstance(), penpot.root).slice(0, 3) as Board[];
* const container = PenpotUtils.createVariantContainer([
* { shape: s, properties: { Size: 'Small' } },
* { shape: m, properties: { Size: 'Medium' } },

View File

@ -352,7 +352,7 @@ Properties are often addressed positionally: `pos` parameter in various methods
Use `penpotUtils.createVariantContainer(components)` — it handles the full multi-step workflow in one call:
```js
// Given three main components s, m, l (here: the first three main components on the page)
const [s, m, l] = penpot.currentPage.findAllShapes(sh => sh.isMainComponent()).slice(0, 3);
const [s, m, l] = penpotUtils.findShapes(sh => sh.isComponentMainInstance(), penpot.root).slice(0, 3);
// Single property:
const container = penpotUtils.createVariantContainer([
{ shape: s, properties: { Size: 'Small' } },

View File

@ -1,17 +1,10 @@
## 1.6.0 (2026-11-10)
## 1.7.0 (Unreleased)
### 🚀 Features
- **plugin-types, plugins-runtime:** `penpotMgmt` returns `Team`, `Project` and `ProjectFile` objects. Assigning `name` renames teams, projects and files, `project.pinned` pins a project and `file.shared` shares a file as a team library; `createProject`, `createFile`, `moveTo`, `duplicate`, `remove` and `restore` live on the objects. Added `penpotMgmt.listTeams`, `penpotMgmt.createTeam`, `penpotMgmt.getFile`, `listDeletedProjects` and `listDeletedFiles`, and the `manage:projects`, `manage:teams` and `manage:delete` permissions. Restoration uses the dashboard's Trash operation; projects need recoverable files and empty projects cannot be restored; the API exposes no permanent deletion method. Team deletion is not exposed.
- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. Global scope adds the `allow:global` permission, which users must accept when installing the plugin.
- **plugin-types:** Added `LibraryComponent.restore()` to restore a deleted component and its main instance without changing the current page, selection, zoom, or viewport. Requires `library:write` permission and only applies to the current file's library.
- **plugin-types:** Added `Shape.updateMain()`, `LibraryComponent.duplicate()`, writable component `annotation`, and `Library.deletedComponents`. Declared the existing `LibraryComponent.remove()` method.
- **plugin-types:** Added `LibraryContext.tokensSource` to read or set the current file's token source to a connected library, or restore local tokens.
- **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently.
- **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs.
- **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished
- **plugin-types:** Added the `contentrollback` event and the `ContentRollback` type. The event fires after the backend rejected some changes and the workspace undid them, with the ids of the files, pages and shapes affected. It fires once the undone data is in the workspace, also when the workspace has to reload the file to undo the changes.
- **plugin-types:** `Stroke` exposes per-side widths for boards and rectangles, also supported by `borderWidth` and `dimension` tokens. Read-only `strokeWidthType` reports `simple` when all four effective widths match, otherwise `multiple`.
### 🩹 Fixes
@ -21,6 +14,25 @@
- **plugins-runtime**: `library.connectLibrary()` now resolves once the library's assets are loaded; it used to resolve with an empty library the first time. `library.connected` and `library.availableLibraries()` ignore files opened earlier in the same tab, which used to show up as connected and be left out of the available libraries.
- **plugins-runtime:** Writes now fail with an error while the workspace cannot take them (saves failing, a user who cannot edit, a version preview, a canvas that stopped rendering), whatever the `throwValidationErrors` flag. They used to change the workspace with edits that would never be saved. The error says why. The Inspect tab does not block writes, and undo blocks still open and close.
- **plugins-runtime:** Writes that wait for the server (`createShapeFromSvgWithImages`, `uploadMediaData`, `uploadMediaUrl`, `connectLibrary`) check again when the server answers. If the workspace turned read-only meanwhile (a save failed), the promise rejects with the read-only error: no shape is added and no library is linked. An image the server already stored stays in the file's media, unused.
- **plugins-runtime:** `Shape.component()` returns `null` for a copy whose library is not available, instead of throwing `Assert failed: (uuid? id)`.
- **plugins-runtime:** `Shape.componentRoot()` returns the root of the shape's own component tree. It used to return a shape that pointed at nothing when the copy was on another page or file than its main, and threw when the copy's library was not available.
- **plugins-runtime:** `LibraryComponent.mainInstance()` returns `null` for a deleted component, instead of a shape with an empty `id` whose `parent` throws.
- **plugins-runtime**: `TokenFontFamilies.resolvedValue` and the `fontFamilies` member of `TokenTypographyValue` now keep a family whose name has several words (`Hanken Grotesk`) as one entry, instead of splitting it into an array of its words, and keep names with words the resolver would otherwise read as colors or numbers (`Red Hat Display`, `Press Start 2P`) unchanged.
## 1.6.0 (2026-11-10)
### 🚀 Features
- **plugin-types:** Added `LibraryComponent.restore()` to restore a deleted component and its main instance without changing the current page, selection, zoom, or viewport. Requires `library:write` permission and only applies to the current file's library.
- **plugin-types:** Added `Shape.updateMain()`, `LibraryComponent.duplicate()`, writable component `annotation`, and `Library.deletedComponents`. Declared the existing `LibraryComponent.remove()` method.
- **plugin-types:** Added `LibraryContext.tokensSource` to read or set the current file's token source to a connected library, or restore local tokens.
- **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently.
- **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs.
- **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished
- **plugin-types:** `Stroke` exposes per-side widths for boards and rectangles, also supported by `borderWidth` and `dimension` tokens. Read-only `strokeWidthType` reports `simple` when all four effective widths match, otherwise `multiple`.
### 🩹 Fixes
- **plugins-runtime:** `LibraryComponent.remove()` now rejects linked-library and missing component handles, and does nothing for an already deleted component.
- **plugins-runtime**: An interaction obtained from `Shape.interactions` now keeps addressing that interaction instead of the position it held when the array was read. Removing every interaction of a shape from a single read removes all of them rather than leaving some behind, and writing through a held interaction after an earlier one is removed no longer lands on a different interaction.
- **plugins-runtime**: `Shape.removeInteraction()` now rejects an interaction belonging to a different shape with a validation error, instead of removing whichever interaction sat at the same position on the target shape.
@ -30,7 +42,6 @@
- **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001.
- **plugins-runtime**: The plugin modal no longer shows two resize grips in Firefox. Firefox now shows only its native grip. Closes #11795.
- **plugins-runtime**: Fixed the `fontFamilies` token property mapping so `Shape.applyToken(token, ["fontFamilies"])` resolves to the canonical `:font-family` attribute and applied-token readback exposes the documented `fontFamilies` key instead of the undocumented singular `fontFamily`. Closes #11405.
- **plugins-runtime**: `TokenFontFamilies.resolvedValue` and the `fontFamilies` member of `TokenTypographyValue` now keep a family whose name has several words (`Hanken Grotesk`) as one entry, instead of splitting it into an array of its words, and keep names with words the resolver would otherwise read as colors or numbers (`Red Hat Display`, `Press Start 2P`) unchanged.
- **plugins-runtime**: Opening a plugin no longer drops running background plugins (`allowBackground`, such as the MCP plugin) from the runtime registry. They kept running, but their UI messages were no longer delivered to the plugin and they could no longer be unloaded, so MCP tasks timed out after another plugin was opened.
- **plugins-runtime**: `Shape.applyToken()`, `Token.applyToShapes()` and `Token.applyToSelected()` now reject an explicit per-side stroke width property (`strokeWidthTop`, `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) on shapes other than boards and rectangles, matching the `strokes` setter. The uniform `strokeWidth` property keeps working on every shape. Closes #12319.