Skip to content
This repository was archived by the owner on Jul 9, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 41 additions & 89 deletions Composer/packages/client/config/extensions.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,101 +3,53 @@ const path = require('path');
const PnpWebpackPlugin = require('pnp-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');

const paths = require('./paths');

module.exports = (webpackEnv) => {
const isEnvDevelopment = webpackEnv === 'development';
const isEnvProduction = webpackEnv === 'production';

return [
{
entry: {
'react-bundle': 'react',
},
mode: isEnvProduction ? 'production' : 'development',
// export react globally under a variable named React
output: {
path: path.resolve(__dirname, '../public'),
library: 'React',
libraryTarget: 'var',
},
resolve: {
extensions: ['.js'],
},
optimization: {
minimize: isEnvProduction,
minimizer: [
new TerserPlugin({
extractComments: false,
}),
],
},
return {
mode: isEnvProduction ? 'production' : 'development',
entry: {
'plugin-host-preload': path.resolve(__dirname, '../extension-container/plugin-host-preload.tsx'),
},
output: {
path: path.resolve(__dirname, '../public'),
filename: '[name].js',
},
{
entry: {
'react-dom-bundle': 'react-dom',
},
mode: isEnvProduction ? 'production' : 'development',
// export react-dom globally under a variable named ReactDOM
output: {
path: path.resolve(__dirname, '../public'),
library: 'ReactDOM',
libraryTarget: 'var',
},
externals: {
// ReactDOM depends on React, but we need this to resolve to the globally-exposed React variable in react-bundle.js (created by extensions.config.js).
// If we don't do this, ReactDom will bundle its own copy of React and we will have 2 copies which breaks hooks.
react: 'React',
},
resolve: {
extensions: ['.js'],
},
optimization: {
minimize: isEnvProduction,
minimizer: [
new TerserPlugin({
extractComments: false,
resolve: {
extensions: ['.js'],
plugins: [PnpWebpackPlugin],
},
resolveLoader: {
plugins: [
// Also related to Plug'n'Play, but this time it tells Webpack to load its loaders
// from the current package.
PnpWebpackPlugin.moduleLoader(module),
],
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: require.resolve('ts-loader'),
include: [path.resolve(__dirname, '../extension-container')],
options: PnpWebpackPlugin.tsLoaderOptions({
transpileOnly: true,
configFile: path.resolve(__dirname, '../tsconfig.build.json'),
}),
],
},
},
],
},
{
mode: isEnvProduction ? 'production' : 'development',
entry: {
'plugin-host-preload': path.resolve(__dirname, '../extension-container/plugin-host-preload.tsx'),
},
output: {
path: path.resolve(__dirname, '../public'),
},
externals: {
// expect react & react-dom to be available in the extension host iframe globally under "React" and "ReactDOM" variables
react: 'React',
'react-dom': 'ReactDOM',
},
resolve: {
extensions: ['.js'],
plugins: [PnpWebpackPlugin],
},
resolveLoader: {
plugins: [
// Also related to Plug'n'Play, but this time it tells Webpack to load its loaders
// from the current package.
PnpWebpackPlugin.moduleLoader(module),
],
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: require.resolve('ts-loader'),
include: [path.resolve(__dirname, '../extension-container')],
options: PnpWebpackPlugin.tsLoaderOptions({
transpileOnly: true,
configFile: path.resolve(__dirname, '../tsconfig.build.json'),
}),
optimization: {
minimize: isEnvProduction,

minimizer: [
new TerserPlugin({
extractComments: false,
terserOptions: {
sourceMap: true,
},
],
},
}),
],
},
];
};
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import React from 'react';
import ReactDOM from 'react-dom';
import * as ExtensionClient from '@bfc/extension-client';
import { syncStore, Shell } from '@bfc/extension-client';

if (!document.head.title) {
const title = document.createElement('title');
Expand Down Expand Up @@ -33,9 +36,27 @@ if (!document.getElementById('plugin-root')) {
document.body.appendChild(root);
}
// initialize the API object
window.React = React;
window.ReactDOM = ReactDOM;
window.ExtensionClient = ExtensionClient;
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
window.Composer = {};

// init the render function
window.Composer.render = function (component) {
window.Composer.render = function (type: string, shell: Shell, component: React.ReactElement) {
// eslint-disable-next-line no-underscore-dangle
window.Composer.__pluginType = type;

if (shell) {
syncStore(shell);
}

ReactDOM.render(component, document.getElementById('plugin-root'));
};

window.Composer.sync = function (shell: Shell) {
syncStore(shell);
};

window.parent?.postMessage('host-preload-complete', '*');
2 changes: 1 addition & 1 deletion Composer/packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"scripts": {
"start": "yarn build:extension-bundles && node scripts/start.js",
"build": "node --max_old_space_size=4096 scripts/build.js",
"build:extension-bundles": "webpack --config ./config/extensions.config.js",
"build:extension-bundles": "webpack --config ./config/extensions.config.js --env production",
"clean": "rimraf build",
"test": "jest",
"lint": "eslint --quiet --ext .js,.jsx,.ts,.tsx ./src ./__tests__",
Expand Down
90 changes: 55 additions & 35 deletions Composer/packages/client/src/components/PluginHost/PluginHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@

/** @jsx jsx */
import { jsx, SerializedStyles } from '@emotion/core';
import * as React from 'react';
import { useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Shell } from '@bfc/types';
import { PluginType } from '@bfc/extension-client';

import { PluginAPI } from '../../plugins/api';
import { PluginType } from '../../plugins/types';

import { iframeStyle } from './styles';

Expand All @@ -16,14 +16,20 @@ interface PluginHostProps {
pluginName: string;
pluginType: PluginType;
bundleId: string;
shell?: Shell;
}

/** Binds closures around Composer client code to plugin iframe's window object */
function attachPluginAPI(win: Window, type: PluginType) {
function attachPluginAPI(win: Window, type: PluginType, shell?: object) {
const api = { ...PluginAPI[type], ...PluginAPI.auth };

for (const method in api) {
win.Composer[method] = (...args) => api[method](...args);
}

// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
win.Composer.render = win.Composer.render.bind(null, type, shell);
}

function injectScript(doc: Document, id: string, src: string, async: boolean, onload?: () => any) {
Expand All @@ -39,38 +45,52 @@ function injectScript(doc: Document, id: string, src: string, async: boolean, on
*/
export const PluginHost: React.FC<PluginHostProps> = (props) => {
const targetRef = useRef<HTMLIFrameElement>(null);
const { extraIframeStyles = [] } = props;
const [isLoaded, setIsLoaded] = useState(false);
const { extraIframeStyles = [], pluginType, pluginName, bundleId, shell } = props;

useEffect(() => {
const { pluginName, pluginType, bundleId } = props;
// renders the plugin's UI inside of the iframe
const renderPluginView = async () => {
if (pluginName && pluginType) {
const iframeWindow = targetRef.current?.contentWindow as Window;
const iframeDocument = targetRef.current?.contentDocument as Document;

// inject the react / react-dom bundles
injectScript(iframeDocument, 'react-bundle', '/react-bundle.js', false);
injectScript(iframeDocument, 'react-dom-bundle', '/react-dom-bundle.js', false);
// // load the preload script to setup the plugin API
injectScript(iframeDocument, 'preload-bundle', '/plugin-host-preload.js', false, () => {
attachPluginAPI(iframeWindow, pluginType);
});

//load the bundle for the specified plugin
const pluginScriptId = `plugin-${pluginType}-${pluginName}`;
await new Promise((resolve) => {
const cb = () => {
resolve();
};
const bundleUri = `/api/extensions/${pluginName}/${bundleId}`;
// If plugin bundles end up being too large and block the client thread due to the load, enable the async flag on this call
injectScript(iframeDocument, pluginScriptId, bundleUri, false, cb);
});
}
};
renderPluginView();
}, [props.pluginName, props.pluginType, props.bundleId, targetRef]);

return <iframe ref={targetRef} css={[iframeStyle, ...extraIframeStyles]} title={`${props.pluginName} host`}></iframe>;
if (pluginName && pluginType) {
const iframeDocument = targetRef.current?.contentDocument as Document;

// // load the preload script to setup the plugin API
injectScript(iframeDocument, 'preload-bundle', '/plugin-host-preload.js', false);

const onPreloaded = (ev) => {
if (ev.data === 'host-preload-complete') {
setIsLoaded(true);
}
};

window.addEventListener('message', onPreloaded);

return () => {
window.removeEventListener('message', onPreloaded);
};
}
}, [pluginName, pluginType, bundleId, targetRef]);

useEffect(() => {
if (isLoaded && pluginType && pluginName && bundleId) {
const iframeWindow = targetRef.current?.contentWindow as Window;
const iframeDocument = targetRef.current?.contentDocument as Document;

attachPluginAPI(iframeWindow, pluginType, shell);

//load the bundle for the specified plugin
const pluginScriptId = `plugin-${pluginType}-${pluginName}`;
const bundleUri = `/api/extensions/${pluginName}/${bundleId}`;
// If plugin bundles end up being too large and block the client thread due to the load, enable the async flag on this call
injectScript(iframeDocument, pluginScriptId, bundleUri, false);
}
}, [isLoaded]);

// sync the shell to the iframe store when shell changes
useEffect(() => {
if (isLoaded && targetRef.current) {
targetRef.current.contentWindow?.Composer.sync(shell);
}
}, [isLoaded, shell]);

return <iframe ref={targetRef} css={[iframeStyle, ...extraIframeStyles]} title={`${pluginName} host`}></iframe>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@ import React from 'react';
import { RouteComponentProps } from '@reach/router';

import { PluginHost } from '../../components/PluginHost/PluginHost';
import { useShell } from '../../shell';

const PluginPageContainer: React.FC<RouteComponentProps<{ pluginId: string; bundleId: string }>> = (props) => {
const { pluginId, bundleId } = props;
const PluginPageContainer: React.FC<RouteComponentProps<{ pluginId: string; bundleId: string; projectId: string }>> = (
props
) => {
const { pluginId, bundleId, projectId } = props;
const shell = useShell('DesignPage', projectId as string);

if (!pluginId || !bundleId) {
if (!pluginId || !bundleId || !projectId) {
return null;
}

return <PluginHost bundleId={bundleId} pluginName={pluginId} pluginType="page"></PluginHost>;
return <PluginHost bundleId={bundleId} pluginName={pluginId} pluginType="page" shell={shell} />;
};

export { PluginPageContainer };
16 changes: 16 additions & 0 deletions Composer/packages/client/src/recoilModel/selectors/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { selector } from 'recoil';

import { extensionsState } from '../atoms/appState';
import { ExtensionPageConfig } from '../../utils/pageLinks';

export const enabledExtensionsSelector = selector({
key: 'enabledExtensionsSelector',
Expand All @@ -13,3 +14,18 @@ export const enabledExtensionsSelector = selector({
return extensions.filter((e) => e.enabled);
},
});

export const pluginPagesSelector = selector({
key: 'pluginPagesSelector',
get: ({ get }) => {
const extensions = get(enabledExtensionsSelector);

return extensions.reduce((pages, p) => {
const pagesConfig = p.contributes?.views?.pages;
if (Array.isArray(pagesConfig) && pagesConfig.length > 0) {
pages.push(...pagesConfig.map((page) => ({ ...page, id: p.id })));
}
return pages;
}, [] as ExtensionPageConfig[]);
},
});
Loading