mirror of
https://github.com/penpot/penpot-plugins.git
synced 2025-03-10 06:41:59 -05:00
feat(plugins-runtime): plugin live reload
This commit is contained in:
parent
e1b5e172ca
commit
bbc77e4127
5 changed files with 181 additions and 72 deletions
|
@ -48,9 +48,6 @@ export let uiMessagesCallbacks: Callback<unknown>[] = [];
|
|||
|
||||
let modals = new Set<PluginModalElement>([]);
|
||||
|
||||
// TODO: Remove when deprecating method `off`
|
||||
let listeners: { [key: string]: Map<object, symbol> } = {};
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
try {
|
||||
for (const callback of uiMessagesCallbacks) {
|
||||
|
@ -70,25 +67,34 @@ export function themeChange(theme: PenpotTheme) {
|
|||
export function createApi(
|
||||
context: PenpotContext,
|
||||
manifest: Manifest,
|
||||
closed: () => void
|
||||
): Penpot {
|
||||
closed: () => void,
|
||||
load: () => void
|
||||
) {
|
||||
let modal: PluginModalElement | null = null;
|
||||
|
||||
const closePlugin = () => {
|
||||
// remove all event listeners
|
||||
// TODO: Remove when deprecating method `off`
|
||||
let listeners: { [key: string]: Map<object, symbol> } = {};
|
||||
|
||||
const removeAllEventListeners = () => {
|
||||
Object.entries(listeners).forEach(([, map]) => {
|
||||
map.forEach((id) => {
|
||||
context.removeListener(id);
|
||||
});
|
||||
});
|
||||
|
||||
uiMessagesCallbacks = [];
|
||||
};
|
||||
|
||||
const closePlugin = () => {
|
||||
removeAllEventListeners();
|
||||
|
||||
if (modal) {
|
||||
modals.delete(modal);
|
||||
|
||||
modal.removeEventListener('close', closePlugin);
|
||||
modal.remove();
|
||||
}
|
||||
uiMessagesCallbacks = [];
|
||||
|
||||
modal = null;
|
||||
|
||||
closed();
|
||||
|
@ -105,12 +111,13 @@ export function createApi(
|
|||
open: (name: string, url: string, options?: OpenUIOptions) => {
|
||||
const theme = context.getTheme() as 'light' | 'dark';
|
||||
|
||||
modal = openUIApi(
|
||||
name,
|
||||
getValidUrl(manifest.host, url),
|
||||
theme,
|
||||
options
|
||||
);
|
||||
const modalUrl = getValidUrl(manifest.host, url);
|
||||
|
||||
if (modal?.getAttribute('iframe-src') === modalUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
modal = openUIApi(name, modalUrl, theme, options);
|
||||
|
||||
modal.setTheme(theme);
|
||||
|
||||
|
@ -118,6 +125,8 @@ export function createApi(
|
|||
once: true,
|
||||
});
|
||||
|
||||
modal.addEventListener('load', load);
|
||||
|
||||
modals.add(modal);
|
||||
},
|
||||
|
||||
|
@ -396,5 +405,8 @@ export function createApi(
|
|||
},
|
||||
};
|
||||
|
||||
return penpot;
|
||||
return {
|
||||
penpot,
|
||||
removeAllEventListeners,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -3,15 +3,26 @@ import { createApi, themeChange, uiMessagesCallbacks } from './index.js';
|
|||
import openUIApi from './openUI.api.js';
|
||||
import type { PenpotFile } from '@penpot/plugin-types';
|
||||
|
||||
const mockUrl = 'http://fake.fake/';
|
||||
|
||||
vi.mock('./openUI.api', () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
addEventListener: vi.fn(),
|
||||
addEventListener: vi
|
||||
.fn()
|
||||
.mockImplementation((type: string, fn: () => void) => {
|
||||
if (type === 'load') {
|
||||
fn();
|
||||
}
|
||||
}),
|
||||
dispatchEvent: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
setAttribute: vi.fn(),
|
||||
setTheme: vi.fn(),
|
||||
getAttribute: () => {
|
||||
return mockUrl;
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
@ -32,26 +43,33 @@ describe('Plugin api', () => {
|
|||
removeListener: vi.fn(),
|
||||
};
|
||||
|
||||
const api = createApi(
|
||||
mockContext as any,
|
||||
{
|
||||
pluginId: 'test',
|
||||
name: 'test',
|
||||
code: '',
|
||||
host: 'http://fake.com',
|
||||
permissions: [
|
||||
'content:read',
|
||||
'content:write',
|
||||
'library:read',
|
||||
'library:write',
|
||||
'user:read',
|
||||
],
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
let api: ReturnType<typeof createApi>;
|
||||
|
||||
const addEventListenerMock = vi.mocked(window.addEventListener);
|
||||
const messageEvent = addEventListenerMock.mock.calls[0][1] as EventListener;
|
||||
const closedCallback = vi.fn();
|
||||
const loadCallback = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
api = createApi(
|
||||
mockContext as any,
|
||||
{
|
||||
pluginId: 'test',
|
||||
name: 'test',
|
||||
code: '',
|
||||
host: mockUrl,
|
||||
permissions: [
|
||||
'content:read',
|
||||
'content:write',
|
||||
'library:read',
|
||||
'library:write',
|
||||
'user:read',
|
||||
],
|
||||
},
|
||||
closedCallback,
|
||||
loadCallback
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
@ -64,7 +82,7 @@ describe('Plugin api', () => {
|
|||
const options = { width: 100, height: 100 };
|
||||
const openUIApiMock = vi.mocked(openUIApi);
|
||||
|
||||
api.ui.open(name, url, options);
|
||||
api.penpot.ui.open(name, url, options);
|
||||
|
||||
const modalMock = openUIApiMock.mock.results[0].value;
|
||||
|
||||
|
@ -74,6 +92,13 @@ describe('Plugin api', () => {
|
|||
expect.any(Function),
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
expect(modalMock.addEventListener).toHaveBeenCalledWith(
|
||||
'load',
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
expect(loadCallback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sendMessage', () => {
|
||||
|
@ -83,8 +108,8 @@ describe('Plugin api', () => {
|
|||
const message = { test: 'test' };
|
||||
const openUIApiMock = vi.mocked(openUIApi);
|
||||
|
||||
api.ui.open(name, url, options);
|
||||
api.ui.sendMessage(message);
|
||||
api.penpot.ui.open(name, url, options);
|
||||
api.penpot.ui.sendMessage(message);
|
||||
|
||||
const modalMock = openUIApiMock.mock.results[0].value;
|
||||
const eventPassedToDispatchEvent =
|
||||
|
@ -104,7 +129,7 @@ describe('Plugin api', () => {
|
|||
|
||||
const callback = vi.fn();
|
||||
|
||||
api.ui.onMessage(callback);
|
||||
api.penpot.ui.onMessage(callback);
|
||||
messageEvent(message);
|
||||
|
||||
expect(uiMessagesCallbacks.length).toEqual(1);
|
||||
|
@ -115,21 +140,21 @@ describe('Plugin api', () => {
|
|||
describe('events', () => {
|
||||
it('invalid event', () => {
|
||||
expect(() => {
|
||||
api.on('invalid' as any, vi.fn());
|
||||
api.penpot.on('invalid' as any, vi.fn());
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('pagechange', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
const id = api.on('pagechange', callback);
|
||||
const id = api.penpot.on('pagechange', callback);
|
||||
expect(mockContext.addListener).toHaveBeenCalled();
|
||||
expect((mockContext.addListener.mock as any).lastCall[0]).toBe(
|
||||
'pagechange'
|
||||
);
|
||||
expect((mockContext.addListener.mock as any).lastCall[1]).toBe(callback);
|
||||
|
||||
api.off(id);
|
||||
api.penpot.off(id);
|
||||
expect(mockContext.removeListener).toHaveBeenCalled();
|
||||
expect((mockContext.removeListener.mock as any).lastCall[0]).toBe(id);
|
||||
});
|
||||
|
@ -137,8 +162,8 @@ describe('Plugin api', () => {
|
|||
it('remove event on close plugin', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
api.on('pagechange', callback);
|
||||
api.closePlugin();
|
||||
api.penpot.on('pagechange', callback);
|
||||
api.penpot.closePlugin();
|
||||
|
||||
expect(mockContext.removeListener).toHaveBeenCalled();
|
||||
});
|
||||
|
@ -152,6 +177,7 @@ describe('Plugin api', () => {
|
|||
code: '',
|
||||
permissions: [],
|
||||
} as any,
|
||||
() => {},
|
||||
() => {}
|
||||
);
|
||||
|
||||
|
@ -159,29 +185,29 @@ describe('Plugin api', () => {
|
|||
const callback = vi.fn();
|
||||
|
||||
expect(() => {
|
||||
api.on('filechange', callback);
|
||||
api.penpot.on('filechange', callback);
|
||||
}).toThrow();
|
||||
|
||||
expect(() => {
|
||||
api.on('pagechange', callback);
|
||||
api.penpot.on('pagechange', callback);
|
||||
}).toThrow();
|
||||
|
||||
expect(() => {
|
||||
api.on('selectionchange', callback);
|
||||
api.penpot.on('selectionchange', callback);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('get states', () => {
|
||||
expect(() => {
|
||||
api.getFile();
|
||||
api.penpot.getFile();
|
||||
}).toThrow();
|
||||
|
||||
expect(() => {
|
||||
api.getPage();
|
||||
api.penpot.getPage();
|
||||
}).toThrow();
|
||||
|
||||
expect(() => {
|
||||
api.getSelected();
|
||||
api.penpot.getSelected();
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
@ -194,7 +220,7 @@ describe('Plugin api', () => {
|
|||
|
||||
mockContext.getPage.mockImplementation(() => examplePage);
|
||||
|
||||
const pageState = api.getPage();
|
||||
const pageState = api.penpot.getPage();
|
||||
|
||||
expect(pageState).toEqual(examplePage);
|
||||
});
|
||||
|
@ -208,7 +234,7 @@ describe('Plugin api', () => {
|
|||
|
||||
mockContext.getFile.mockImplementation(() => exampleFile);
|
||||
|
||||
const fileState = api.getFile();
|
||||
const fileState = api.penpot.getFile();
|
||||
|
||||
expect(fileState).toEqual(exampleFile);
|
||||
});
|
||||
|
@ -218,43 +244,68 @@ describe('Plugin api', () => {
|
|||
|
||||
mockContext.getSelected.mockImplementation(() => selection);
|
||||
|
||||
const currentSelection = api.getSelected();
|
||||
const currentSelection = api.penpot.getSelected();
|
||||
|
||||
expect(currentSelection).toEqual(selection);
|
||||
});
|
||||
|
||||
it('set theme refresh modal theme', () => {
|
||||
const name = 'test';
|
||||
const url = 'http://fake.com';
|
||||
const url = mockUrl;
|
||||
const options = { width: 100, height: 100 };
|
||||
const openUIApiMock = vi.mocked(openUIApi);
|
||||
|
||||
mockContext.getTheme.mockImplementation(() => 'light');
|
||||
|
||||
api.ui.open(name, url, options);
|
||||
api.penpot.ui.open(name, url, options);
|
||||
|
||||
const modalMock = openUIApiMock.mock.results[0].value;
|
||||
expect(modalMock.setTheme).toHaveBeenCalledWith('light');
|
||||
expect(api.getTheme()).toBe('light');
|
||||
expect(api.penpot.getTheme()).toBe('light');
|
||||
|
||||
themeChange('dark');
|
||||
expect(modalMock.setTheme).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('should not open twice the same url', () => {
|
||||
const name = 'test';
|
||||
const url = mockUrl;
|
||||
const options = { width: 100, height: 100 };
|
||||
const openUIApiMock = vi.mocked(openUIApi);
|
||||
|
||||
api.penpot.ui.open(name, url, options);
|
||||
api.penpot.ui.open(name, url, options);
|
||||
|
||||
expect(openUIApiMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('open twice diffennt url', () => {
|
||||
const name = 'test';
|
||||
const url = mockUrl + '1';
|
||||
const options = { width: 100, height: 100 };
|
||||
const openUIApiMock = vi.mocked(openUIApi);
|
||||
|
||||
api.penpot.ui.open(name, url, options);
|
||||
api.penpot.ui.open(name, url, options);
|
||||
|
||||
expect(openUIApiMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('close plugin', () => {
|
||||
const name = 'test';
|
||||
const url = 'http://fake.com';
|
||||
const url = mockUrl;
|
||||
const options = { width: 100, height: 100 };
|
||||
const openUIApiMock = vi.mocked(openUIApi);
|
||||
const callback = vi.fn();
|
||||
|
||||
api.ui.open(name, url, options);
|
||||
api.ui.onMessage(callback);
|
||||
api.penpot.ui.open(name, url, options);
|
||||
api.penpot.ui.onMessage(callback);
|
||||
|
||||
api.closePlugin();
|
||||
api.penpot.closePlugin();
|
||||
|
||||
const modalMock = openUIApiMock.mock.results[0].value;
|
||||
|
||||
expect(closedCallback).toHaveBeenCalled();
|
||||
expect(modalMock.remove).toHaveBeenCalled();
|
||||
expect(modalMock.removeEventListener).toHaveBeenCalled();
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
|
|
@ -58,7 +58,10 @@ describe('loadPlugin', () => {
|
|||
} as unknown as PenpotContext;
|
||||
|
||||
mockApi = {
|
||||
closePlugin: vi.fn(),
|
||||
penpot: {
|
||||
closePlugin: vi.fn(),
|
||||
},
|
||||
removeAllEventListeners: vi.fn(),
|
||||
} as unknown as ReturnType<typeof createApi>;
|
||||
|
||||
vi.mocked(createApi).mockReturnValue(mockApi);
|
||||
|
@ -79,6 +82,7 @@ describe('loadPlugin', () => {
|
|||
expect(createApi).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
manifest,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
@ -100,7 +104,7 @@ describe('loadPlugin', () => {
|
|||
await loadPlugin(manifest);
|
||||
await loadPlugin(manifest);
|
||||
|
||||
expect(mockApi.closePlugin).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi.penpot.closePlugin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should remove finish event listener on plugin finish', async () => {
|
||||
|
@ -141,7 +145,7 @@ describe('loadPlugin', () => {
|
|||
|
||||
await loadPlugin(manifest);
|
||||
|
||||
expect(mockApi.closePlugin).toHaveBeenCalled();
|
||||
expect(mockApi.penpot.closePlugin).toHaveBeenCalled();
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
@ -163,4 +167,22 @@ describe('loadPlugin', () => {
|
|||
Object.keys(plugin.compartment.globalThis).filter((it) => !!it).length
|
||||
).toBe(0);
|
||||
});
|
||||
it('should re-evaluate and remove listeners if plugin reload', async () => {
|
||||
const plugin = await loadPlugin(manifest);
|
||||
|
||||
if (!plugin) {
|
||||
throw new Error('Plugin not loaded');
|
||||
}
|
||||
|
||||
const onLoad = vi.mocked(createApi).mock.calls[0][3];
|
||||
// regular load
|
||||
await onLoad();
|
||||
|
||||
// reload
|
||||
await onLoad();
|
||||
|
||||
expect(mockApi.removeAllEventListeners).toHaveBeenCalledOnce();
|
||||
expect(evaluateMock).toHaveBeenCalledTimes(2);
|
||||
expect(loadManifestCode).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { PenpotContext, PenpotTheme } from '@penpot/plugin-types';
|
||||
import type { Penpot, PenpotContext, PenpotTheme } from '@penpot/plugin-types';
|
||||
|
||||
import { createApi } from './api/index.js';
|
||||
import { loadManifest, loadManifestCode } from './parse-manifest.js';
|
||||
|
@ -19,7 +19,7 @@ export function setContextBuilder(builder: ContextBuilder) {
|
|||
|
||||
const closeAllPlugins = () => {
|
||||
createdApis.forEach((pluginApi) => {
|
||||
pluginApi.closePlugin();
|
||||
pluginApi.penpot.closePlugin();
|
||||
});
|
||||
|
||||
createdApis = [];
|
||||
|
@ -34,6 +34,11 @@ export const loadPlugin = async function (manifest: Manifest) {
|
|||
}
|
||||
|
||||
context.addListener('themechange', (e: PenpotTheme) => api.themeChange(e));
|
||||
const listenerId: symbol = context.addListener('finish', () => {
|
||||
closeAllPlugins();
|
||||
|
||||
context?.removeListener(listenerId);
|
||||
});
|
||||
|
||||
const code = await loadManifestCode(manifest);
|
||||
|
||||
|
@ -43,7 +48,7 @@ export const loadPlugin = async function (manifest: Manifest) {
|
|||
closeAllPlugins();
|
||||
}
|
||||
|
||||
const pluginApi = createApi(context, manifest, () => {
|
||||
const onClose = () => {
|
||||
createdApis = createdApis.filter((api) => api !== pluginApi);
|
||||
|
||||
timeouts.forEach(clearTimeout);
|
||||
|
@ -53,14 +58,30 @@ export const loadPlugin = async function (manifest: Manifest) {
|
|||
Object.keys(publicPluginApi).forEach((key) => {
|
||||
delete c.globalThis[key];
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
let loaded = false;
|
||||
|
||||
const onLoad = async () => {
|
||||
if (!loaded) {
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
pluginApi.removeAllEventListeners();
|
||||
|
||||
const code = await loadManifestCode(manifest);
|
||||
c.evaluate(code);
|
||||
};
|
||||
|
||||
const pluginApi = createApi(context, manifest, onClose, onLoad);
|
||||
|
||||
createdApis.push(pluginApi);
|
||||
|
||||
const timeouts = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const publicPluginApi = {
|
||||
penpot: ses.harden(pluginApi) as typeof pluginApi,
|
||||
penpot: ses.harden(pluginApi.penpot) as Penpot,
|
||||
fetch: ses.harden((...args: Parameters<typeof fetch>) => {
|
||||
const requestArgs: RequestInit = {
|
||||
...args[1],
|
||||
|
@ -93,12 +114,6 @@ export const loadPlugin = async function (manifest: Manifest) {
|
|||
|
||||
c.evaluate(code);
|
||||
|
||||
const listenerId: symbol = context.addListener('finish', () => {
|
||||
closeAllPlugins();
|
||||
|
||||
context?.removeListener(listenerId);
|
||||
});
|
||||
|
||||
return {
|
||||
compartment: c,
|
||||
publicPluginApi,
|
||||
|
|
|
@ -100,6 +100,15 @@ export class PluginModalElement extends HTMLElement {
|
|||
'allow-storage-access-by-user-activation'
|
||||
);
|
||||
|
||||
iframe.addEventListener('load', () => {
|
||||
this.shadowRoot?.dispatchEvent(
|
||||
new CustomEvent('load', {
|
||||
composed: true,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
this.addEventListener('message', (e: Event) => {
|
||||
if (!iframe.contentWindow) {
|
||||
return;
|
||||
|
|
Loading…
Add table
Reference in a new issue