Support react-refresh and shared client/server types (#98)

- Adds support for react-refresh through `@pmmwh/react-refresh-webpack-plugin`. Updated webpack config to support this. Means entire app is no longer refreshed on changes, only individual components, and state is maintained.
- React refresh removes need for `google-apps-script-webpack-dev-server` package
- In client code, support for server function autocomplete through use of gas-client 1.0.0. Requires certain files to be typescript .ts files
- Remove need for "global" exports in main server file through updated gas-webpack-plugin
This commit is contained in:
Elisha Nuchi
2022-03-14 12:31:52 -04:00
committed by GitHub
parent 89c12fa161
commit 531a952df3
15 changed files with 1636 additions and 1402 deletions
@@ -11,7 +11,7 @@ jobs:
os: [macos-10.15, macos-latest, windows-latest] os: [macos-10.15, macos-latest, windows-latest]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/ # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
node-version: [12, 14, 16] node-version: [12, 14, 16]
timeout-minutes: 10 timeout-minutes: 11
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
+6 -1
View File
@@ -2,6 +2,9 @@
<a href="" rel="noopener"> <a href="" rel="noopener">
<img width="400" src="https://i.imgur.com/83Y7bWN.png" alt="React & Google Apps Script logos"></a> <img width="400" src="https://i.imgur.com/83Y7bWN.png" alt="React & Google Apps Script logos"></a>
</p> </p>
<p align="center"><i>
Update 2022: Now with support for React v17 and React Fast Refresh
</i></p>
<div align="center"> <div align="center">
@@ -12,7 +15,7 @@
</div> </div>
<p align="center"> This is your boilerplate project for developing React apps inside Google Sheets, Docs, Forms and Slides projects. It's perfect for personal projects and for publishing complex add-ons in the G Suite Marketplace. <p align="center"> 🚀 This is your boilerplate project for developing React apps inside Google Sheets, Docs, Forms and Slides projects. It's perfect for personal projects and for publishing complex add-ons in the G Suite Marketplace.
</p> </p>
--- ---
@@ -189,6 +192,8 @@ After running the start command, navigate to your spreadsheet and open one of th
<img width="100%" src="https://i.imgur.com/EsnOEHP.gif"> <img width="100%" src="https://i.imgur.com/EsnOEHP.gif">
Support for [Fast Refresh](https://github.com/pmmmwh/react-refresh-webpack-plugin) now means that only modified components are refreshed when files are changed, and state is not lost.
<br/> <br/>
### 🔍 Using React DevTools <a name="dev-tools"></a> ### 🔍 Using React DevTools <a name="dev-tools"></a>
+2 -7
View File
@@ -1,8 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import server from '../src/client/utils/server'; import { serverFunctions } from '../src/client/utils/serverFunctions.ts';
const { serverFunctions } = server;
const { FILENAME, PORT } = process.env; const { FILENAME, PORT } = process.env;
@@ -54,10 +52,7 @@ const DevServer = () => {
position: 'absolute', position: 'absolute',
}} }}
ref={iframe} ref={iframe}
// The "/gas/" path here must match the path where the custom dev server is being loaded. src={`https://localhost:${PORT}/${FILENAME}-impl.html`}
// See webpack.config.js "devServer" "before" settings.
// Filename extension "-impl" must match webpack.config.js clientConfigs filename settings for development.
src={`https://localhost:${PORT}/gas/${FILENAME}-impl.html`}
/> />
</div> </div>
); );
+1510 -1329
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "react-google-apps-script", "name": "react-google-apps-script",
"version": "2.0.0", "version": "2.1.0",
"description": "Starter project for using React with Google Apps Script", "description": "Starter project for using React with Google Apps Script",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -34,7 +34,6 @@
"npm": ">=6.0.0" "npm": ">=6.0.0"
}, },
"dependencies": { "dependencies": {
"gas-client": "^0.2.1",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^17.0.2", "react": "^17.0.2",
"react-bootstrap": "^1.3.0", "react-bootstrap": "^1.3.0",
@@ -51,7 +50,8 @@
"@babel/polyfill": "^7.10.4", "@babel/polyfill": "^7.10.4",
"@babel/preset-env": "^7.11.0", "@babel/preset-env": "^7.11.0",
"@babel/preset-react": "^7.10.4", "@babel/preset-react": "^7.10.4",
"@google/clasp": "^2.3.1", "@google/clasp": "2.3.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
"@types/expect-puppeteer": "^4.4.6", "@types/expect-puppeteer": "^4.4.6",
"@types/jest-environment-puppeteer": "^4.4.1", "@types/jest-environment-puppeteer": "^4.4.1",
"@types/puppeteer": "^5.4.4", "@types/puppeteer": "^5.4.4",
@@ -66,11 +66,10 @@
"css-loader": "^3.6.0", "css-loader": "^3.6.0",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"dynamic-cdn-webpack-plugin": "^5.0.0", "dynamic-cdn-webpack-plugin": "^5.0.0",
"eslint": "^6.8.0", "eslint": "^8.11.0",
"eslint-config-airbnb-base": "^14.2.0", "eslint-config-airbnb-base": "^14.2.0",
"eslint-config-prettier": "^6.11.0", "eslint-config-prettier": "^6.11.0",
"eslint-config-standard": "^14.1.1", "eslint-config-standard": "^14.1.1",
"eslint-loader": "^3.0.4",
"eslint-plugin-babel": "^5.3.1", "eslint-plugin-babel": "^5.3.1",
"eslint-plugin-googleappsscript": "^1.0.3", "eslint-plugin-googleappsscript": "^1.0.3",
"eslint-plugin-import": "^2.22.0", "eslint-plugin-import": "^2.22.0",
@@ -81,10 +80,10 @@
"eslint-plugin-promise": "^4.2.1", "eslint-plugin-promise": "^4.2.1",
"eslint-plugin-react": "^7.20.5", "eslint-plugin-react": "^7.20.5",
"eslint-plugin-standard": "^4.0.1", "eslint-plugin-standard": "^4.0.1",
"gas-client": "^1.0.0",
"gas-lib": "^2.0.3", "gas-lib": "^2.0.3",
"gas-types-detailed": "^1.0.0", "gas-types-detailed": "^1.0.0",
"gas-webpack-plugin": "^1.0.4", "gas-webpack-plugin": "^1.2.3",
"google-apps-script-webpack-dev-server": "^1.0.0",
"html-webpack-inline-source-plugin": "0.0.10", "html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"jest": "^27.5.1", "jest": "^27.5.1",
@@ -95,6 +94,7 @@
"puppeteer": "^13.4.1", "puppeteer": "^13.4.1",
"puppeteer-extra": "^3.2.3", "puppeteer-extra": "^3.2.3",
"puppeteer-extra-plugin-stealth": "^2.9.0", "puppeteer-extra-plugin-stealth": "^2.9.0",
"react-refresh": "^0.10.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"style-loader": "^1.2.1", "style-loader": "^1.2.1",
"tern": "^0.24.3", "tern": "^0.24.3",
+13 -1
View File
@@ -29,7 +29,19 @@
"camelcase": "warn", "camelcase": "warn",
"import/prefer-default-export": "warn", "import/prefer-default-export": "warn",
"import/no-extraneous-dependencies": "warn", "import/no-extraneous-dependencies": "warn",
"prefer-object-spread": "warn" "prefer-object-spread": "warn",
"rules": {
"import/extensions": [
"error",
"ignorePackages",
{
"js": "never",
"jsx": "never",
"ts": "never",
"tsx": "never"
}
]
}
}, },
"settings": { "settings": {
"react": { "react": {
@@ -4,9 +4,7 @@ import { Button, ListGroup } from 'react-bootstrap';
import FormInput from './FormInput.tsx'; import FormInput from './FormInput.tsx';
// This is a wrapper for google.script.run that lets us use promises. // This is a wrapper for google.script.run that lets us use promises.
import server from '../../utils/server'; import { serverFunctions } from '../../utils/serverFunctions';
const { serverFunctions } = server;
const SheetEditor = () => { const SheetEditor = () => {
const [names, setNames] = useState([]); const [names, setNames] = useState([]);
@@ -4,9 +4,7 @@ import FormInput from './FormInput';
import SheetButton from './SheetButton'; import SheetButton from './SheetButton';
// This is a wrapper for google.script.run that lets us use promises. // This is a wrapper for google.script.run that lets us use promises.
import server from '../../utils/server'; import { serverFunctions } from '../../utils/serverFunctions';
const { serverFunctions } = server;
const SheetEditor = () => { const SheetEditor = () => {
const [names, setNames] = useState([]); const [names, setNames] = useState([]);
-10
View File
@@ -1,10 +0,0 @@
import Server from 'gas-client';
const { PORT } = process.env;
const server = new Server({
// this is necessary for local development but will be ignored in production
allowedDevelopmentDomains: `https://localhost:${PORT}`,
});
export default server;
+10
View File
@@ -0,0 +1,10 @@
import { GASClient } from 'gas-client';
import * as publicServerFunctions from '../../server';
const { serverFunctions } = new GASClient<typeof publicServerFunctions>({
// this is necessary for local development but will be ignored in production
allowedDevelopmentDomains: origin =>
/https:\/\/.*\.googleusercontent\.com$/.test(origin),
});
export { serverFunctions };
-12
View File
@@ -1,12 +0,0 @@
import * as publicUiFunctions from './ui';
import * as publicSheetFunctions from './sheets';
// Expose public functions by attaching to `global`
global.onOpen = publicUiFunctions.onOpen;
global.openDialog = publicUiFunctions.openDialog;
global.openDialogBootstrap = publicUiFunctions.openDialogBootstrap;
global.openAboutSidebar = publicUiFunctions.openAboutSidebar;
global.getSheetsData = publicSheetFunctions.getSheetsData;
global.addSheet = publicSheetFunctions.addSheet;
global.deleteSheet = publicSheetFunctions.deleteSheet;
global.setActiveSheet = publicSheetFunctions.setActiveSheet;
+20
View File
@@ -0,0 +1,20 @@
import {
onOpen,
openDialog,
openDialogBootstrap,
openAboutSidebar,
} from './ui';
import { getSheetsData, addSheet, deleteSheet, setActiveSheet } from './sheets';
// Public functions must be exported as named exports
export {
onOpen,
openDialog,
openDialogBootstrap,
openAboutSidebar,
getSheetsData,
addSheet,
deleteSheet,
setActiveSheet,
};
+3 -2
View File
@@ -18,7 +18,7 @@ const toMatchImageSnapshot = configureToMatchImageSnapshot({
allowSizeMismatch: true, allowSizeMismatch: true,
}); });
expect.extend({ toMatchImageSnapshot }); expect.extend({ toMatchImageSnapshot });
jest.setTimeout(120000); jest.setTimeout(180000);
const srcTestFile = path.join( const srcTestFile = path.join(
__dirname, __dirname,
@@ -48,10 +48,11 @@ describe(`Local setup ${isExtended ? '*extended*' : ''}`, () => {
deviceScaleFactor: 1, deviceScaleFactor: 1,
}); });
await webpackDevServerReady(process);
if (isExtended) { if (isExtended) {
await openAddon(page); await openAddon(page);
} else { } else {
await webpackDevServerReady(process);
await page.goto('https://localhost:3000/dialog-demo-bootstrap.html'); await page.goto('https://localhost:3000/dialog-demo-bootstrap.html');
await page.waitForTimeout(3000); await page.waitForTimeout(3000);
} }
+20 -7
View File
@@ -1,14 +1,16 @@
const openAddon = async page => { const openAddon = async page => {
await page.goto(process.env.SHEET_URL); await page.goto(process.env.SHEET_URL);
await page.waitForTimeout(5000);
await page.click('a:nth-child(2)'); // click on signin button await page.click('a:nth-child(2)'); // click on signin button
await page.waitForTimeout(3000);
await page.waitForSelector('input[name="identifier"]', { visible: true });
await page.type('input[name="identifier"]', process.env.EMAIL); // type email await page.type('input[name="identifier"]', process.env.EMAIL); // type email
await page.click('#identifierNext'); // click "next" button await page.click('#identifierNext'); // click "next" button
await page.waitForTimeout(3000);
await page.waitForSelector('input[name="password"]', { visible: true });
await page.type('input[name="password"]', process.env.PASSWORD); // type pass await page.type('input[name="password"]', process.env.PASSWORD); // type pass
await page.waitForTimeout(3000); await page.waitForTimeout(500);
await page.click('#passwordNext'); // click "next" button await page.click('#passwordNext'); // click "next" button
await page.waitForTimeout(3000); await page.waitForTimeout(3000);
@@ -35,7 +37,10 @@ const openAddon = async page => {
await page.waitForTimeout(5000); await page.waitForTimeout(5000);
} }
await page.waitForTimeout(25000); // wait long enough for onopen to be called await page.waitForSelector(
'div.menu-button.goog-control.goog-inline-block:nth-child(11)',
{ visible: true }
);
// open new addon menubar item // open new addon menubar item
await page.evaluate(() => { await page.evaluate(() => {
@@ -48,7 +53,10 @@ const openAddon = async page => {
addOnMenuButton.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); addOnMenuButton.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
}); });
await page.waitForTimeout(6000); await page.waitForSelector(
'div.goog-menu.goog-menu-vertical.apps-menu-hide-mnemonics:last-child > div:nth-child(2) > div',
{ visible: true }
);
// open "bootstrap" menu item // open "bootstrap" menu item
await page.evaluate(() => { await page.evaluate(() => {
@@ -68,7 +76,12 @@ const openAddon = async page => {
new MouseEvent('mouseup', { bubbles: true }) new MouseEvent('mouseup', { bubbles: true })
); );
}); });
await page.waitForTimeout(10000); await page.waitForSelector('.script-app-dialog', {
visible: true,
timeout: 10000,
});
await page.waitForTimeout(3000);
}; };
module.exports = { openAddon }; module.exports = { openAddon };
+42 -19
View File
@@ -11,6 +11,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin');
const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin'); const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin');
const DynamicCdnWebpackPlugin = require('dynamic-cdn-webpack-plugin'); const DynamicCdnWebpackPlugin = require('dynamic-cdn-webpack-plugin');
const moduleToCdn = require('module-to-cdn'); const moduleToCdn = require('module-to-cdn');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
/********************************* /*********************************
* set up environment variables * set up environment variables
@@ -32,7 +33,7 @@ const isProd = process.env.NODE_ENV === 'production';
const destination = path.resolve(__dirname, 'dist'); const destination = path.resolve(__dirname, 'dist');
// define server paths // define server paths
const serverEntry = './src/server/index.js'; const serverEntry = './src/server/index.ts';
// define appsscript.json file path // define appsscript.json file path
const copyAppscriptEntry = './appsscript.json'; const copyAppscriptEntry = './appsscript.json';
@@ -98,7 +99,7 @@ const sharedClientAndServerConfig = {
}; };
// webpack settings used by all client entrypoints // webpack settings used by all client entrypoints
const clientConfig = { const clientConfig = ({ isDevClientWrapper }) => ({
...sharedClientAndServerConfig, ...sharedClientAndServerConfig,
mode: isProd ? 'production' : 'development', mode: isProd ? 'production' : 'development',
output: { output: {
@@ -119,6 +120,14 @@ const clientConfig = {
use: [ use: [
{ {
loader: 'babel-loader', loader: 'babel-loader',
// only enable react-refresh for dev builds, and not when building the dev client "wrapper"
options: {
plugins: [
!isProd &&
!isDevClientWrapper &&
require.resolve('react-refresh/babel'),
].filter(Boolean),
},
}, },
{ {
loader: 'ts-loader', loader: 'ts-loader',
@@ -130,6 +139,14 @@ const clientConfig = {
exclude: /node_modules/, exclude: /node_modules/,
use: { use: {
loader: 'babel-loader', loader: 'babel-loader',
// only enable react-refresh for dev builds, and not when building the dev client "wrapper"
options: {
plugins: [
!isProd &&
!isDevClientWrapper &&
require.resolve('react-refresh/babel'),
].filter(Boolean),
},
}, },
}, },
// we could add support for scss here // we could add support for scss here
@@ -139,7 +156,7 @@ const clientConfig = {
}, },
], ],
}, },
}; });
// DynamicCdnWebpackPlugin settings // DynamicCdnWebpackPlugin settings
// these settings help us load 'react', 'react-dom' and the packages defined below from a CDN // these settings help us load 'react', 'react-dom' and the packages defined below from a CDN
@@ -150,9 +167,19 @@ const DynamicCdnWebpackPluginConfig = {
resolver: (packageName, packageVersion, options) => { resolver: (packageName, packageVersion, options) => {
const packageSuffix = isProd ? '.min.js' : '.js'; const packageSuffix = isProd ? '.min.js' : '.js';
const moduleDetails = moduleToCdn(packageName, packageVersion, options); const moduleDetails = moduleToCdn(packageName, packageVersion, options);
// don't externalize react during development due to issue with react-refresh
// https://github.com/pmmmwh/react-refresh-webpack-plugin/issues/334
if (!isProd && packageName === 'react') {
return null;
}
// return defaults if Dynamic CDN plugin finds package
if (moduleDetails) { if (moduleDetails) {
return moduleDetails; return moduleDetails;
} }
// define custom CDN configuration for new packages
// "name" should match the package being imported // "name" should match the package being imported
// "var" is important to get right -- this should be the exposed global. Look up "webpack externals" for info. // "var" is important to get right -- this should be the exposed global. Look up "webpack externals" for info.
switch (packageName) { switch (packageName) {
@@ -178,11 +205,14 @@ const DynamicCdnWebpackPluginConfig = {
// webpack settings used by each client entrypoint defined at top // webpack settings used by each client entrypoint defined at top
const clientConfigs = clientEntrypoints.map(clientEntrypoint => { const clientConfigs = clientEntrypoints.map(clientEntrypoint => {
const isDevClientWrapper = false;
return { return {
...clientConfig, ...clientConfig({ isDevClientWrapper }),
name: clientEntrypoint.name, name: clientEntrypoint.name,
entry: clientEntrypoint.entry, entry: clientEntrypoint.entry,
plugins: [ plugins: [
!isProd && new webpack.HotModuleReplacementPlugin(),
!isProd && new ReactRefreshWebpackPlugin(),
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env': JSON.stringify(envVars), 'process.env': JSON.stringify(envVars),
}), }),
@@ -195,26 +225,15 @@ const clientConfigs = clientEntrypoints.map(clientEntrypoint => {
new HtmlWebpackInlineSourcePlugin(), new HtmlWebpackInlineSourcePlugin(),
// this plugin allows us to add dynamically load packages from a CDN // this plugin allows us to add dynamically load packages from a CDN
new DynamicCdnWebpackPlugin(DynamicCdnWebpackPluginConfig), new DynamicCdnWebpackPlugin(DynamicCdnWebpackPluginConfig),
], ].filter(Boolean),
}; };
}); });
const gasWebpackDevServerPath = require.resolve(
'google-apps-script-webpack-dev-server'
);
// webpack settings for devServer https://webpack.js.org/configuration/dev-server/ // webpack settings for devServer https://webpack.js.org/configuration/dev-server/
const devServer = { const devServer = {
hot: true,
port: PORT, port: PORT,
https: true, https: true,
// run our own route to serve the package google-apps-script-webpack-dev-server
before: app => {
// this '/gas/' path needs to match the path loaded in the iframe in dev/index.js
app.get('/gas/*', (req, res) => {
res.setHeader('Content-Type', 'text/html');
fs.createReadStream(gasWebpackDevServerPath).pipe(res);
});
},
}; };
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) { if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
@@ -238,8 +257,9 @@ if (fs.existsSync(pfxPath)) {
// webpack settings for the development client wrapper // webpack settings for the development client wrapper
const devClientConfigs = clientEntrypoints.map(clientEntrypoint => { const devClientConfigs = clientEntrypoints.map(clientEntrypoint => {
envVars.FILENAME = clientEntrypoint.filename; envVars.FILENAME = clientEntrypoint.filename;
const isDevClientWrapper = true;
return { return {
...clientConfig, ...clientConfig({ isDevClientWrapper }),
name: `DEVELOPMENT: ${clientEntrypoint.name}`, name: `DEVELOPMENT: ${clientEntrypoint.name}`,
entry: devDialogEntry, entry: devDialogEntry,
plugins: [ plugins: [
@@ -332,7 +352,10 @@ const serverConfig = {
isProd ? 'production' : 'development' isProd ? 'production' : 'development'
), ),
}), }),
new GasPlugin(), new GasPlugin({
// removes need for assigning public server functions to "global"
autoGlobalExportsFiles: [serverEntry],
}),
], ],
}; };