diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index 9fb646f205..5839ed57a4 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -1043,7 +1043,12 @@ ids (into #{} (map #(obj/get % "$id")) shapes)] (st/emit! (-> (dwl/add-component id-ref ids) (se/add-event plugin-id))) - (lib-component-proxy plugin-id file-id @id-ref)))) + ;; add-component only sets id-ref when it actually creates a + ;; component; an empty selection or shapes that can't form one + ;; leave it nil, so reject instead of returning a broken proxy. + (if-let [id @id-ref] + (lib-component-proxy plugin-id file-id id) + (u/not-valid plugin-id :createComponent "Cannot create a component from the given shapes"))))) ;; Plugin data :getPluginData diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 323e8319ab..eaae151372 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -6,6 +6,7 @@ ### 🩹 Fixes +- **plugins-runtime**: `Library.createComponent()` now rejects invalid input (an empty shape list, or a shape inside a component copy) with a validation error instead of returning a component proxy pointing at nothing. - **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again. ## 1.5.0 (2026-07-08) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts index 7d3a8ccf0a..32da4f5a59 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts @@ -1,6 +1,6 @@ import { expect } from '../framework/expect'; import { describe, test } from '../framework/registry'; -import type { Text } from '@penpot/plugin-types'; +import type { Board, Text } from '@penpot/plugin-types'; import type { TestContext } from '../framework/types'; import { PNG_1X1 } from './fixtures'; @@ -310,5 +310,59 @@ describe('Library', () => { expect(instance).toBeDefined(); expect(typeof instance.id).toBe('string'); }); + + // Reported (via MCP): create a page-root shape (rectangle / ellipse / text), + // save it as a component, then use it from Assets — the component root was + // said to be a plain shape instead of a board/component. This does not + // reproduce: a lone page-root shape is wrapped in a board and that board + // becomes the component (main-instance) root. Pin the invariant for the + // exact repro. + test('createComponent wraps a page-root shape in a board component root', (ctx) => { + // A genuine page-root shape: created but NOT placed inside any board. + const rect = ctx.penpot.createRectangle(); + const comp = ctx.penpot.library.local.createComponent([rect]); + + const main = comp.mainInstance() as Board; + expect(main.type).toBe('board'); + expect(main.isComponentRoot()).toBeTruthy(); + expect(main.isComponentMainInstance()).toBeTruthy(); + expect(main.children.some((c) => c.id === rect.id)).toBe(true); + + const errors = ctx.penpot.currentFile?.validate() ?? []; + expect(errors.map((e) => e.code)).toEqual([]); + + // The generated main instance lands at the page root (outside ctx.board); + // tuck it under the scratch board so teardown removes it. + ctx.board.appendChild(main); + }); + + // createComponent must reject inputs that cannot form a component rather + // than succeeding silently or crashing. Both cases below currently surface + // an INTERNAL assertion ("Assert failed: (uuid? id)"): the operation is a + // no-op, so the returned LibraryComponent proxy is built from a nil id. It + // should be a clean rejection (or a null return) instead — and in a release + // build, where the assertion is elided, the caller gets a broken component + // proxy pointing at nothing. The file must stay valid regardless. + test('createComponent with no shapes is rejected', (ctx) => { + expect(() => ctx.penpot.library.local.createComponent([])).toThrow(); + const errors = ctx.penpot.currentFile?.validate() ?? []; + expect(errors.map((e) => e.code)).toEqual([]); + }); + + test('createComponent from a shape inside a component copy is rejected', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const source = ctx.penpot.library.local.createComponent([rect]); + const copy = source.instance() as Board; + ctx.board.appendChild(copy); + const copyChild = copy.children[0]; + expect(copyChild).toBeDefined(); + + expect(() => + ctx.penpot.library.local.createComponent([copyChild]), + ).toThrow(); + const errors = ctx.penpot.currentFile?.validate() ?? []; + expect(errors.map((e) => e.code)).toEqual([]); + }); }); });