0
Fork 0
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:
Juanfran 2024-08-08 09:32:45 +02:00
parent e1b5e172ca
commit bbc77e4127
5 changed files with 181 additions and 72 deletions

View file

@ -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,
};
}

View file

@ -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,13 +43,21 @@ describe('Plugin api', () => {
removeListener: vi.fn(),
};
const api = createApi(
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: 'http://fake.com',
host: mockUrl,
permissions: [
'content:read',
'content:write',
@ -47,11 +66,10 @@ describe('Plugin api', () => {
'user:read',
],
},
() => {}
closedCallback,
loadCallback
);
const addEventListenerMock = vi.mocked(window.addEventListener);
const messageEvent = addEventListenerMock.mock.calls[0][1] as EventListener;
});
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();

View file

@ -58,7 +58,10 @@ describe('loadPlugin', () => {
} as unknown as PenpotContext;
mockApi = {
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);
});
});

View file

@ -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,

View file

@ -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;