🐛 Fix problems with plugins api setPluginData (#10632)

This commit is contained in:
Alonso Torres 2026-07-09 18:00:26 +02:00 committed by GitHub
parent 64b0bff7bd
commit d6c50cc40b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 127 additions and 2 deletions

View File

@ -1059,6 +1059,9 @@
:setPluginData
(fn [key value]
(cond
(not= file-id (:current-file-id @st/state))
(u/not-valid plugin-id :setPluginData-non-local-library file-id)
(not (string? key))
(u/not-valid plugin-id :setPluginData-key key)
@ -1092,6 +1095,9 @@
:setSharedPluginData
(fn [namespace key value]
(cond
(not= file-id (:current-file-id @st/state))
(u/not-valid plugin-id :setSharedPluginData-non-local-library file-id)
(not (string? namespace))
(u/not-valid plugin-id :setSharedPluginData-namespace namespace)

View File

@ -272,13 +272,13 @@
(st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value))))
:getSharedPluginDataKeys
(fn [self namespace]
(fn [namespace]
(cond
(not (string? namespace))
(u/not-valid plugin-id :page-plugin-data-namespace namespace)
:else
(let [page (u/proxy->page self)]
(let [page (u/locate-page file-id id)]
(apply array (keys (dm/get-in page [:plugin-data (keyword "shared" namespace)]))))))
:openPage

View File

@ -52,6 +52,8 @@
- **plugins-runtime**: `penpot.openPage()` (and `Page.openPage()`) now resolves immediately when the target page is already active, instead of waiting forever for a page-initialization event that never fires.
- **plugins-runtime**: `Shape.shadows`, `Shape.exports` and grid `rows`/`columns` now return live proxies, so writing a member on a returned shadow/export/track (e.g. `shape.shadows[0].blur = 7`) persists to the shape instead of mutating a detached snapshot that was silently discarded. The shadow `color` remains a plain snapshot (reconfigure it by assigning `shadow.color`).
- **plugins-runtime**: Setting a variant component's `path` now renames the whole variant (its container and every main instance), like the `name` setter already did, instead of renaming only the component and leaving the file referentially inconsistent (which the backend rejected on save with a `variant-component-bad-name` error).
- **plugins-runtime**: `Page.getSharedPluginDataKeys(namespace)` now works instead of always raising a namespace validation error: the implementation expected a spurious leading argument, so the caller's `namespace` was read as a missing second argument.
- **plugins-runtime**: Storing plugin data on a connected (non-local) shared library is now consistently rejected with a `setPluginData-non-local-library` error on the `Library` object as well as its assets (colors, typographies, components). Previously the `Library` object accepted the write and applied it optimistically, but plugin data is not part of library synchronization and the change only persists when the caller can edit the library file — on a read-only shared library it failed silently and was lost on reload. Plugin data can only be stored on the file currently being edited.
## 1.4.2 (2026-01-21)

View File

@ -34,6 +34,24 @@ describe('Library', () => {
expect(Array.isArray(ctx.penpot.library.connected)).toBe(true);
});
// The Library object itself carries plugin data (it extends PluginData),
// stored on the underlying file. Exercised on the local library; the
// connected-library case (writing to a shared library's file/assets) shares
// the same code path but can't be fixtured here (connecting a library hangs).
test('local library stores plugin data', (ctx) => {
const lib = ctx.penpot.library.local;
lib.setPluginData('k', 'v');
expect(lib.getPluginData('k')).toBe('v');
expect(lib.getPluginDataKeys()).toContain('k');
});
test('local library stores shared plugin data', (ctx) => {
const lib = ctx.penpot.library.local;
lib.setSharedPluginData('ns', 'k', 'v');
expect(lib.getSharedPluginData('ns', 'k')).toBe('v');
expect(lib.getSharedPluginDataKeys('ns')).toContain('k');
});
test('library elements expose a libraryId', (ctx) => {
const color = ctx.penpot.library.local.createColor();
expect(typeof color.libraryId).toBe('string');
@ -136,6 +154,13 @@ describe('Library', () => {
expect(color.getPluginData('k')).toBe('v');
expect(color.getPluginDataKeys()).toContain('k');
});
test('color shared plugin data round-trips', (ctx) => {
const color = ctx.penpot.library.local.createColor();
color.setSharedPluginData('ns', 'k', 'v');
expect(color.getSharedPluginData('ns', 'k')).toBe('v');
expect(color.getSharedPluginDataKeys('ns')).toContain('k');
});
});
describe('Typographies', () => {
@ -170,6 +195,13 @@ describe('Library', () => {
expect(typo.getPluginDataKeys()).toContain('k');
});
test('typography shared plugin data round-trips', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
typo.setSharedPluginData('ns', 'k', 'v');
expect(typo.getSharedPluginData('ns', 'k')).toBe('v');
expect(typo.getSharedPluginDataKeys('ns')).toContain('k');
});
test('typography fontFamily and fontId round-trip', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
expect(typeof typo.fontFamily).toBe('string');
@ -256,6 +288,15 @@ describe('Library', () => {
expect(comp.getPluginDataKeys()).toContain('k');
});
test('component shared plugin data round-trips', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const comp = ctx.penpot.library.local.createComponent([rect]);
comp.setSharedPluginData('ns', 'k', 'v');
expect(comp.getSharedPluginData('ns', 'k')).toBe('v');
expect(comp.getSharedPluginDataKeys('ns')).toContain('k');
});
test('component instance and mainInstance return shapes', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);

View File

@ -26,6 +26,41 @@ describe('Plugin data', () => {
if (file) {
file.setPluginData('fileKey', 'fileValue');
expect(file.getPluginData('fileKey')).toBe('fileValue');
expect(file.getPluginDataKeys()).toContain('fileKey');
}
});
test('shared plugin data round-trips on the file', (ctx) => {
const file = ctx.penpot.currentFile;
expect(file).not.toBeNull();
if (file) {
file.setSharedPluginData('ns', 'fileShared', 'fileSharedValue');
expect(file.getSharedPluginData('ns', 'fileShared')).toBe(
'fileSharedValue',
);
expect(file.getSharedPluginDataKeys('ns')).toContain('fileShared');
}
});
test('plugin data round-trips on a page', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
page.setPluginData('pageKey', 'pageValue');
expect(page.getPluginData('pageKey')).toBe('pageValue');
expect(page.getPluginDataKeys()).toContain('pageKey');
}
});
test('shared plugin data round-trips on a page', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
page.setSharedPluginData('ns', 'pageShared', 'pageSharedValue');
expect(page.getSharedPluginData('ns', 'pageShared')).toBe(
'pageSharedValue',
);
expect(page.getSharedPluginDataKeys('ns')).toContain('pageShared');
}
});
@ -45,6 +80,47 @@ describe('Plugin data', () => {
expect(rect.getPluginDataKeys()).toContain('');
});
// Keys are opaque strings: no case normalization is applied. A camelCase key
// and its kebab-case spelling are distinct entries that each round-trip
// independently. Pins that behaviour so a future key-normalization regression
// (reported as camelCase keys "behaving incorrectly") would be caught here.
test('camelCase and kebab-case keys are distinct and both round-trip', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.setPluginData('myKey', 'camel');
rect.setPluginData('my-key', 'kebab');
expect(rect.getPluginData('myKey')).toBe('camel');
expect(rect.getPluginData('my-key')).toBe('kebab');
const keys = rect.getPluginDataKeys();
expect(keys).toContain('myKey');
expect(keys).toContain('my-key');
});
// Same guarantee at the file-data storage location (a distinct code path from
// shape/page objects), covering a camelCase key on the file itself.
test('a camelCase key round-trips on the file', (ctx) => {
const file = ctx.penpot.currentFile;
expect(file).not.toBeNull();
if (file) {
file.setPluginData('camelCaseFileKey', 'value');
expect(file.getPluginData('camelCaseFileKey')).toBe('value');
expect(file.getPluginDataKeys()).toContain('camelCaseFileKey');
}
});
// camelCase is likewise preserved in the shared namespace and the shared key.
test('camelCase shared namespace and key round-trip', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.setSharedPluginData('myNamespace', 'myKey', 'camel');
rect.setSharedPluginData('myNamespace', 'my-key', 'kebab');
expect(rect.getSharedPluginData('myNamespace', 'myKey')).toBe('camel');
expect(rect.getSharedPluginData('myNamespace', 'my-key')).toBe('kebab');
const keys = rect.getSharedPluginDataKeys('myNamespace');
expect(keys).toContain('myKey');
expect(keys).toContain('my-key');
});
test('setPluginData with a non-string value throws', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);