diff --git a/flow/application-config.js.flow b/flow/application-config.js.flow index 4bae014e4..f9991a1fd 100644 --- a/flow/application-config.js.flow +++ b/flow/application-config.js.flow @@ -33,6 +33,7 @@ declare module 'application-config' { changed: string[], }, enableErrorOverlay: boolean, + inlineAllCss: boolean, [key: string]: any, }; diff --git a/packages/gluestick/src/__tests__/mocks/context.js b/packages/gluestick/src/__tests__/mocks/context.js index 0d3e59b1d..6875df880 100644 --- a/packages/gluestick/src/__tests__/mocks/context.js +++ b/packages/gluestick/src/__tests__/mocks/context.js @@ -58,11 +58,15 @@ const gsConfig: GSConfig = { changed: [], }, enableErrorOverlay: true, + inlineAllCss: false, }; const client: WebpackConfig = { resolve: {}, module: {}, + output: { + publicPath: '/assets', + }, }; const server: WebpackConfig = {}; diff --git a/packages/gluestick/src/config/defaults/glueStickConfig.js b/packages/gluestick/src/config/defaults/glueStickConfig.js index 5130e1d6e..26e6887d1 100644 --- a/packages/gluestick/src/config/defaults/glueStickConfig.js +++ b/packages/gluestick/src/config/defaults/glueStickConfig.js @@ -64,6 +64,7 @@ const config: GSConfig = { ], }, enableErrorOverlay: true, + inlineAllCss: false, }; module.exports = config; diff --git a/packages/gluestick/src/config/webpack/ChunksPlugin.js b/packages/gluestick/src/config/webpack/ChunksPlugin.js index f6d7d48a6..82041df3e 100644 --- a/packages/gluestick/src/config/webpack/ChunksPlugin.js +++ b/packages/gluestick/src/config/webpack/ChunksPlugin.js @@ -1,19 +1,10 @@ /* @flow */ -import type { WebpackConfig } from '../../types'; +import type { ChunksInfo, ChunkInfo, WebpackConfig } from '../../types'; const path = require('path'); const fs = require('fs'); const mkdir = require('mkdirp'); -type ChunksInfo = { - javascript: { - [key: ?string]: string, - }, - styles: { - [key: ?string]: string, - }, -}; - const chunkInfoFilePath = ( webpackConfiguration: WebpackConfig, chunkInfoFilename?: string = 'webpack-chunks.json', @@ -22,8 +13,8 @@ const chunkInfoFilePath = ( return path.join(webpackConfiguration.output.path, chunkInfoFilename); }; -const getChunksInfoBody = (json: Object, publicPath: string): ChunksInfo => { - const assetsByChunk: Object = json.assetsByChunkName; +const getChunksInfoBody = (stats: Object, publicPath: string): ChunksInfo => { + const assetsByChunk = stats.assetsByChunkName; const assetsChunks: ChunksInfo = { javascript: {}, @@ -31,8 +22,8 @@ const getChunksInfoBody = (json: Object, publicPath: string): ChunksInfo => { }; // gets asset paths by name and extension of their chunk - const getAssets = (chunkName: string, extension: string): string[] => { - let chunk: string | string[] = json.assetsByChunkName[chunkName]; + const getAssets = (chunkName: string, extension: string): ChunkInfo[] => { + let chunk: string | string[] = stats.assetsByChunkName[chunkName]; // a chunk could be a string or an array, so make sure it is an array if (!Array.isArray(chunk)) { @@ -41,18 +32,18 @@ const getChunksInfoBody = (json: Object, publicPath: string): ChunksInfo => { return chunk .filter(name => path.extname(name) === `.${extension}`) - .map(name => `${publicPath}${name}`); + .map(name => ({ url: `${publicPath}${name}`, name })); }; Object.keys(assetsByChunk).forEach((name: string) => { // The second asset is usually a source map - const jsAsset: string = getAssets(name, 'js')[0]; + const jsAsset = getAssets(name, 'js')[0]; if (jsAsset) { assetsChunks.javascript[name] = jsAsset; } - const styleAsset: string = getAssets(name, 'css')[0]; + const styleAsset = getAssets(name, 'css')[0]; if (styleAsset) { assetsChunks.styles[name] = styleAsset; @@ -77,18 +68,18 @@ module.exports = class ChunksPlugin { } apply(compiler: Object): void { - const outputFilePath: string = chunkInfoFilePath( + const outputFilePath = chunkInfoFilePath( this.configuration, this.options.chunkInfoFilename, ); compiler.plugin('done', (stats: Object) => { - const json: Object = stats.toJson({ + const json = stats.toJson({ context: this.configuration.context || process.cwd(), chunkModules: true, }); - const publicPath: string = + const publicPath = process.env.NODE_ENV !== 'production' && this.configuration.devServer && typeof this.configuration.devServer.publicPath === 'string' diff --git a/packages/gluestick/src/config/webpack/__tests__/ChunksPlugin.test.js b/packages/gluestick/src/config/webpack/__tests__/ChunksPlugin.test.js index 4c5704694..ffc864189 100644 --- a/packages/gluestick/src/config/webpack/__tests__/ChunksPlugin.test.js +++ b/packages/gluestick/src/config/webpack/__tests__/ChunksPlugin.test.js @@ -4,11 +4,11 @@ jest.mock( 'path/webpack-chunks', () => ({ javascript: { - main: 'publicPath/main.js', - profile: 'publicPath/profile.js', + main: { name: 'main.js', url: 'publicPath/main.js' }, + profile: { name: 'profile.js', url: 'publicPath/profile.js' }, }, styles: { - main: 'publicPath/main.css', + main: { name: 'main.css', url: 'publicPath/main.css' }, }, }), { virtual: true }, @@ -34,11 +34,11 @@ describe('ChunksPlugin', () => { }); expect(JSON.parse(fs.readFileSync('path/webpack-chunks.json'))).toEqual({ javascript: { - main: 'publicPath/main.js', - profile: 'publicPath/profile.js', + main: { name: 'main.js', url: 'publicPath/main.js' }, + profile: { name: 'profile.js', url: 'publicPath/profile.js' }, }, styles: { - main: 'publicPath/main.css', + main: { name: 'main.css', url: 'publicPath/main.css' }, }, }); fs.unlinkSync('path/webpack-chunks.json'); @@ -63,13 +63,13 @@ describe('ChunksPlugin', () => { }); expect(JSON.parse(fs.readFileSync('path/webpack-chunks'))).toEqual({ javascript: { - main: 'publicPath/main.js', - profile: 'publicPath/profile.js', - home: 'publicPath/home.js', + main: { name: 'main.js', url: 'publicPath/main.js' }, + profile: { name: 'profile.js', url: 'publicPath/profile.js' }, + home: { name: 'home.js', url: 'publicPath/home.js' }, }, styles: { - main: 'publicPath/main.css', - home: 'publicPath/home.css', + main: { name: 'main.css', url: 'publicPath/main.css' }, + home: { name: 'home.css', url: 'publicPath/home.css' }, }, }); fs.unlinkSync('path/webpack-chunks.json'); diff --git a/packages/gluestick/src/renderer/__tests__/render.test.js b/packages/gluestick/src/renderer/__tests__/render.test.js index 3febcc02a..dd19a07dd 100644 --- a/packages/gluestick/src/renderer/__tests__/render.test.js +++ b/packages/gluestick/src/renderer/__tests__/render.test.js @@ -180,7 +180,7 @@ describe('renderer/render', () => { }; }; - it('should prepare plugins and pass it to EntryWrapper', () => { + it('should prepare plugins and pass it to EntryWrapper', async () => { const entriesRuntimePlugins = [ { name: 'plugin1', body: v => v, meta: { wrapper: true } }, { name: 'plugin2', body: () => {}, meta: {} }, @@ -199,7 +199,7 @@ describe('renderer/render', () => { const currentRoute = clone(renderProps); currentRoute.email = false; currentRoute.cache = false; - render( + await render( context, request, { diff --git a/packages/gluestick/src/renderer/helpers/__tests__/linkAssets.test.js b/packages/gluestick/src/renderer/helpers/__tests__/linkAssets.test.js index 9fd45213a..a5a020066 100644 --- a/packages/gluestick/src/renderer/helpers/__tests__/linkAssets.test.js +++ b/packages/gluestick/src/renderer/helpers/__tests__/linkAssets.test.js @@ -1,110 +1,166 @@ /* @flow */ -jest.mock('fs'); -jest.mock('path'); - -const path = require('path'); -const fs = require('fs'); -const linkAssets = require('../linkAssets'); const context = require('../../../__tests__/mocks/context').context; const assets = { javascript: { - main: 'main-js', - vendor: 'vendor-js', + main: { name: 'main.js', url: 'publicPath/main.js' }, + vendor: { name: 'vendor.js', url: 'publicPath/vendor.js' }, }, styles: { - main: 'main-style', - vendor: 'vendor-style', + main: { name: 'main.css', url: 'publicPath/main.css' }, + vendor: { name: 'vendor.css', url: 'publicPath/vendor.css' }, }, }; describe('renderer/helpers/linkAssets', () => { - it('should return scripts and style tags', () => { - const { styleTags, scriptTags } = linkAssets(context, 'main', assets, {}); - expect(styleTags.length).toBe(2); - expect(scriptTags.length).toBe(1); - expect(scriptTags[0].type).toEqual('script'); - expect( - scriptTags[0].props.dangerouslySetInnerHTML.__html.includes('main-js'), - ).toBeTruthy(); - expect( - scriptTags[0].props.dangerouslySetInnerHTML.__html.includes('vendor-js'), - ).toBeTruthy(); - expect(styleTags[0].type).toEqual('link'); + afterEach(() => { + jest.resetModules(); }); - it('should resolve / entry name', () => { - const originalENV = process.env.NODE_ENV; - process.env.NODE_ENV = 'production'; - const { styleTags, scriptTags } = linkAssets(context, '/', assets, {}); - expect(styleTags.length).toBe(2); - expect(scriptTags.length).toBe(1); - expect(scriptTags[0].type).toEqual('script'); - expect( - scriptTags[0].props.dangerouslySetInnerHTML.__html.includes('main-js'), - ).toBeTruthy(); - expect( - scriptTags[0].props.dangerouslySetInnerHTML.__html.includes('vendor-js'), - ).toBeTruthy(); - expect(styleTags[0].type).toEqual('link'); - process.env.NODE_ENV = originalENV; - }); + describe('when inlining CSS', () => { + let originalInlineCss; + + beforeEach(() => { + originalInlineCss = context.config.GSConfig.inlineAllCss; + context.config.GSConfig.inlineAllCss = true; + // promisify does not work well with mocks! + jest.mock('util', () => ({ + promisify: () => async filename => { + if (filename === 'root/build/assets/main.css') { + return 'main-css-contents'; + } else if (filename === 'root/build/assets/vendor.css') { + return 'vendor-css-contents'; + } + throw new Error(`File not found: ${filename}`); + }, + })); + jest.spyOn(process, 'cwd').mockImplementation(() => 'root'); + }); - it('should resolve / entry name', () => { - const originalENV = process.env.NODE_ENV; - process.env.NODE_ENV = 'production'; - const { styleTags, scriptTags } = linkAssets(context, '/main', assets, {}); - expect(styleTags.length).toBe(2); - expect(scriptTags.length).toBe(1); - expect(scriptTags[0].type).toEqual('script'); - expect( - scriptTags[0].props.dangerouslySetInnerHTML.__html.includes('main-js'), - ).toBeTruthy(); - expect( - scriptTags[0].props.dangerouslySetInnerHTML.__html.includes('vendor-js'), - ).toBeTruthy(); - expect(styleTags[0].type).toEqual('link'); - process.env.NODE_ENV = originalENV; + afterEach(() => { + context.config.GSConfig.inlineAllCss = originalInlineCss; + }); + + it('inlines the contents of the CSS file', async () => { + const linkAssets = require('../linkAssets'); + const { styleTags } = await linkAssets(context, 'main', assets, {}); + expect(styleTags[0].props.dangerouslySetInnerHTML.__html).toBe( + 'main-css-contents', + ); + expect(styleTags[1].props.dangerouslySetInnerHTML.__html).toBe( + 'vendor-css-contents', + ); + }); }); - it('should pass loadjs config', () => { - const { scriptTags } = linkAssets(context, 'main', assets, { - before: () => { - console.log('LoadJSBefore'); - }, + describe('when not inlining CSS', () => { + it('returns scripts and style tags', async () => { + const linkAssets = require('../linkAssets'); + const { styleTags, scriptTags } = await linkAssets( + context, + 'main', + assets, + {}, + ); + expect(styleTags.length).toBe(2); + expect(scriptTags.length).toBe(1); + expect(scriptTags[0].type).toEqual('script'); + expect(scriptTags[0].props.dangerouslySetInnerHTML.__html).toContain( + 'publicPath/main.js', + ); + expect(scriptTags[0].props.dangerouslySetInnerHTML.__html).toContain( + 'publicPath/vendor.js', + ); + expect(styleTags[0].props.href).toBe('publicPath/main.css'); + expect(styleTags[1].props.href).toBe('publicPath/vendor.css'); + }); + + it('resolves entry name', async () => { + const linkAssets = require('../linkAssets'); + const { styleTags, scriptTags } = await linkAssets( + context, + '/', + assets, + {}, + ); + expect(styleTags.length).toBe(2); + expect(scriptTags.length).toBe(1); + expect(scriptTags[0].type).toEqual('script'); + expect(scriptTags[0].props.dangerouslySetInnerHTML.__html).toContain( + 'publicPath/main.js', + ); + expect(scriptTags[0].props.dangerouslySetInnerHTML.__html).toContain( + 'publicPath/vendor.js', + ); + expect(styleTags[0].props.href).toBe('publicPath/main.css'); + expect(styleTags[1].props.href).toBe('publicPath/vendor.css'); + }); + + it('resolves / entry name', async () => { + const linkAssets = require('../linkAssets'); + const { styleTags, scriptTags } = await linkAssets( + context, + '/main', + assets, + {}, + ); + expect(styleTags.length).toBe(2); + expect(scriptTags.length).toBe(1); + expect(scriptTags[0].type).toEqual('script'); + expect(scriptTags[0].props.dangerouslySetInnerHTML.__html).toContain( + 'publicPath/main.js', + ); + expect(scriptTags[0].props.dangerouslySetInnerHTML.__html).toContain( + 'publicPath/vendor.js', + ); + expect(styleTags[0].props.href).toBe('publicPath/main.css'); + expect(styleTags[1].props.href).toBe('publicPath/vendor.css'); + }); + + it('passes loadjs config', async () => { + const linkAssets = require('../linkAssets'); + const { scriptTags } = await linkAssets(context, 'main', assets, { + before: () => { + console.log('LoadJSBefore'); + }, + }); + expect(scriptTags.length).toBe(1); + expect(scriptTags[0].props.dangerouslySetInnerHTML.__html).toContain( + "console.log('LoadJSBefore')", + ); }); - expect(scriptTags.length).toBe(1); - expect(scriptTags[0].props.dangerouslySetInnerHTML.__html).toContain( - "console.log('LoadJSBefore')", - ); }); - it('should link vendor DLL bundle', () => { - global.__webpack_public_path__ = null; - path.join.mockImplementationOnce(() => 'vendor-manifest.json'); - fs.writeFileSync( - 'vendor-manifest.json', - JSON.stringify({ name: 'vendor_hash' }), - ); - const { scriptTags } = linkAssets( - context, - '/main', - { - ...assets, - javascript: { - main: assets.javascript.main, + describe('vendor DLL bundle', () => { + beforeEach(() => { + jest.mock('fs', () => ({ + readFile: jest.fn(), + readFileSync: () => new Buffer(JSON.stringify({ name: 'vendor_hash' })), + writeFileSync: jest.fn(), + })); + }); + + it('links vendor DLL bundle', async () => { + global.__webpack_public_path__ = null; + const linkAssets = require('../linkAssets'); + const { scriptTags } = await linkAssets( + context, + '/main', + { + ...assets, + javascript: { + main: assets.javascript.main, + }, }, - }, - {}, - ); - expect(scriptTags.length).toBe(1); - expect( - scriptTags[0].props.dangerouslySetInnerHTML.__html.includes('main-js'), - ).toBeTruthy(); - expect( - scriptTags[0].props.dangerouslySetInnerHTML.__html.includes( + {}, + ); + expect(scriptTags.length).toBe(1); + expect(scriptTags[0].props.dangerouslySetInnerHTML.__html).toContain( + 'main.js', + ); + expect(scriptTags[0].props.dangerouslySetInnerHTML.__html).toContain( '/assets/dlls/vendor-hash.dll.js', - ), - ).toBeTruthy(); + ); + }); }); }); diff --git a/packages/gluestick/src/renderer/helpers/linkAssets.js b/packages/gluestick/src/renderer/helpers/linkAssets.js index 5109d4c1d..604d0d36e 100644 --- a/packages/gluestick/src/renderer/helpers/linkAssets.js +++ b/packages/gluestick/src/renderer/helpers/linkAssets.js @@ -1,20 +1,25 @@ /* @flow */ -import type { Context } from '../../types'; +import type { ChunksInfo, Context } from '../../types'; const React = require('react'); const path = require('path'); const fs = require('fs'); +// $FlowIgnore promisify is not available in this version of Flow +const { promisify } = require('util'); const getAssetsLoader = require('./getAssetsLoader'); -const getAssetPathForFile = ( +const readFileAsync = promisify(fs.readFile); // (A) + +// available in webpack-compiled code +declare var __webpack_public_path__: string; // eslint-disable-line camelcase + +const getAssetsForFile = ( filename: string, section: string, - webpackAssets: Object, -): string => { - const assets: Object = webpackAssets[section] || {}; - const webpackPath: string = assets[filename]; - return webpackPath; + webpackAssets: ChunksInfo, +) => { + return webpackAssets[section] && webpackAssets[section][filename]; }; const filterEntryName = (name: string): string => { @@ -26,69 +31,85 @@ const filterEntryName = (name: string): string => { }; const getBundleName = ({ config }): string => { - const manifestFilename: string = - process.env.GS_VENDOR_MANIFEST_FILENAME || ''; + const manifestFilename = process.env.GS_VENDOR_MANIFEST_FILENAME || ''; const { buildDllPath } = config.GSConfig; - const manifestPath: string = path.join( - process.cwd(), - buildDllPath, - manifestFilename, - ); + const manifestPath = path.join(process.cwd(), buildDllPath, manifestFilename); // Can't require it, because it will throw an error on server const { name } = JSON.parse(fs.readFileSync(manifestPath).toString()); - // $FlowIgnore Server is compiled by webpack, so then we have access to webpack's public path - const publicPath: string = __webpack_public_path__ || '/assets/'; // eslint-disable-line camelcase,no-undef + const publicPath: string = __webpack_public_path__ || '/assets/'; // eslint-disable-line camelcase return `${publicPath}dlls/${name.replace('_', '-')}.dll.js`; }; -module.exports = function linkAssets( +// Cache contents of CSS files to avoid hitting the filesystem +const cache = {}; + +const memoizedRead = async (name: string) => { + if (cache[name]) { + return cache[name]; + } + + const localPath = path.join(process.cwd(), 'build', 'assets', name); + const contents = await readFileAsync(localPath); + + cache[name] = contents.toString(); + return cache[name]; +}; + +module.exports = async function linkAssets( { config }: Context, entryPoint: string, assets: Object, loadjsConfig: Object, -): { styleTags: Object[], scriptTags: Object[] } { - const styleTags: Object[] = []; - const scriptTags: Object[] = []; - let key: number = 0; - const entryPointName: string = filterEntryName(entryPoint); +) { + const styleTags = []; + const scriptTags = []; + let key = 0; + const entryPointName = filterEntryName(entryPoint); - const stylesHref: ?string = getAssetPathForFile( - entryPointName, - 'styles', - assets, - ); - if (stylesHref) { - styleTags.push( - , - ); + const styles = getAssetsForFile(entryPointName, 'styles', assets); + if (styles) { + if (config.GSConfig.inlineAllCss) { + const contents = await memoizedRead(styles.name); + styleTags.push( +