diff --git a/libs/plugins-runtime/src/lib/api/index.ts b/libs/plugins-runtime/src/lib/api/index.ts index ee0f0c6..dcab865 100644 --- a/libs/plugins-runtime/src/lib/api/index.ts +++ b/libs/plugins-runtime/src/lib/api/index.ts @@ -48,9 +48,6 @@ export let uiMessagesCallbacks: Callback[] = []; let modals = new Set([]); -// TODO: Remove when deprecating method `off` -let listeners: { [key: string]: Map } = {}; - 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 } = {}; + + 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, + }; } diff --git a/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts b/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts index 3d9dbb6..7da4b55 100644 --- a/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts +++ b/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts @@ -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; 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(); diff --git a/libs/plugins-runtime/src/lib/load-plugin.spec.ts b/libs/plugins-runtime/src/lib/load-plugin.spec.ts index 4fd340a..4c170e0 100644 --- a/libs/plugins-runtime/src/lib/load-plugin.spec.ts +++ b/libs/plugins-runtime/src/lib/load-plugin.spec.ts @@ -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; 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); + }); }); diff --git a/libs/plugins-runtime/src/lib/load-plugin.ts b/libs/plugins-runtime/src/lib/load-plugin.ts index 7a77383..8eb3838 100644 --- a/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/libs/plugins-runtime/src/lib/load-plugin.ts @@ -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>(); const publicPluginApi = { - penpot: ses.harden(pluginApi) as typeof pluginApi, + penpot: ses.harden(pluginApi.penpot) as Penpot, fetch: ses.harden((...args: Parameters) => { 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, diff --git a/libs/plugins-runtime/src/lib/modal/plugin-modal.ts b/libs/plugins-runtime/src/lib/modal/plugin-modal.ts index 082d433..4151415 100644 --- a/libs/plugins-runtime/src/lib/modal/plugin-modal.ts +++ b/libs/plugins-runtime/src/lib/modal/plugin-modal.ts @@ -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;