diff --git a/packages/app/babel.config.js b/packages/app/babel.config.js index 3282bc9111f..9800146f270 100644 --- a/packages/app/babel.config.js +++ b/packages/app/babel.config.js @@ -15,7 +15,6 @@ module.exports = { '@babel/plugin-transform-runtime', '@babel/plugin-syntax-dynamic-import', 'babel-plugin-lodash', - 'babel-plugin-system-import-transformer', 'babel-plugin-macros', 'babel-plugin-styled-components', '@babel/plugin-transform-react-display-name', diff --git a/packages/app/src/sandbox/eval/evaluator.ts b/packages/app/src/sandbox/eval/evaluator.ts new file mode 100644 index 00000000000..5b73510e68a --- /dev/null +++ b/packages/app/src/sandbox/eval/evaluator.ts @@ -0,0 +1,3 @@ +export interface IEvaluator { + evaluate(path: string, basePath?: string): Promise; +} diff --git a/packages/app/src/sandbox/eval/manager.ts b/packages/app/src/sandbox/eval/manager.ts index 5837623e848..723eaa96657 100644 --- a/packages/app/src/sandbox/eval/manager.ts +++ b/packages/app/src/sandbox/eval/manager.ts @@ -30,6 +30,7 @@ import { ignoreNextCache, deleteAPICache, clearIndexedDBCache } from './cache'; import { shouldTranspile } from './transpilers/babel/check'; import { splitQueryFromPath } from './utils/query-path'; import { measure, endMeasure } from '../utils/metrics'; +import { IEvaluator } from './evaluator'; declare const BrowserFS: any; @@ -97,7 +98,7 @@ type TManagerOptions = { hasFileResolver: boolean; }; -export default class Manager { +export default class Manager implements IEvaluator { id: string; transpiledModules: { [path: string]: { @@ -190,6 +191,12 @@ export default class Manager { } } + async evaluate(path: string, basePath: string = '/'): Promise { + const module = await this.resolveModuleAsync(path, basePath); + await this.transpileModules(module); + return this.evaluateModule(module); + } + async initializeTestRunner() { if (this.testRunner) { return this.testRunner; diff --git a/packages/app/src/sandbox/eval/presets/index.test.js b/packages/app/src/sandbox/eval/presets/index.test.js deleted file mode 100644 index 355658557d4..00000000000 --- a/packages/app/src/sandbox/eval/presets/index.test.js +++ /dev/null @@ -1,158 +0,0 @@ -import Preset from './index'; - -import Transpiler from '../transpilers/index'; - -function createDummyTranspiler(name: string) { - const Klass = class Trans extends Transpiler { - constructor() { - super(name); - } - }; - - return new Klass(); -} - -describe('sandbox', () => { - describe('preset', () => { - describe('query', () => { - const preset = new Preset('test', [], []); - - preset.registerTranspiler(t => t.path.endsWith('.js'), [ - { transpiler: createDummyTranspiler('babel-loader') }, - ]); - preset.registerTranspiler(t => t.path.endsWith('.css'), [ - { transpiler: createDummyTranspiler('style-loader') }, - { transpiler: createDummyTranspiler('modules-loader') }, - ]); - - it('generates the right query for 1 transpiler', () => { - const module = { - path: 'test.js', - code: '', - }; - - expect(preset.getQuery(module)).toEqual('!babel-loader'); - }); - - it('generates the right query for 2 transpiler', () => { - const module = { - path: 'test.css', - code: '', - }; - - expect(preset.getQuery(module)).toEqual('!style-loader!modules-loader'); - }); - - it('generates the right query for absolute custom query', () => { - const module = { - path: 'test.css', - code: '', - }; - - expect(preset.getQuery(module, '!babel-loader')).toEqual( - '!babel-loader' - ); - }); - - it('generates the right query for custom query', () => { - const module = { - path: 'test.css', - code: '', - }; - - expect(preset.getQuery(module, 'babel-loader')).toEqual( - '!style-loader!modules-loader!babel-loader' - ); - }); - }); - - describe('alias', () => { - function createPreset(aliases) { - return new Preset('test', [], aliases); - } - - it('finds the right simple alias', () => { - const preset = createPreset({ - test: 'test2', - }); - - expect(preset.getAliasedPath('test')).toBe('test2'); - }); - - it('chooses the right simple alias', () => { - const preset = createPreset({ - test: 'test2', - testtest: 'test4', - }); - - expect(preset.getAliasedPath('testtest')).toBe('test4'); - }); - - it('works with paths', () => { - const preset = createPreset({ - test: 'test2', - testtest: 'test4', - }); - - expect(preset.getAliasedPath('test/piano/guitar')).toBe( - 'test2/piano/guitar' - ); - }); - - it('works with deeper paths', () => { - const preset = createPreset({ - test: 'test4', - 'test/piano': 'test2', - }); - - expect(preset.getAliasedPath('test/piano/guitar')).toBe('test2/guitar'); - }); - - it('works in a real life scenario', () => { - const preset = createPreset({ - preact$: 'preact', - // preact-compat aliases for supporting React dependencies: - react: 'preact-compat', - 'react-dom': 'preact-compat', - 'create-react-class': 'preact-compat/lib/create-react-class', - 'react-addons-css-transition-group': 'preact-css-transition-group', - }); - - expect(preset.getAliasedPath('react/render')).toBe( - 'preact-compat/render' - ); - }); - - it("doesn't replace partial paths", () => { - const preset = createPreset({ - preact$: 'preact', - // preact-compat aliases for supporting React dependencies: - react: 'preact-compat', - 'react-dom': 'preact-compat', - 'create-react-class': 'preact-compat/lib/create-react-class', - 'react-addons-css-transition-group': 'preact-css-transition-group', - }); - - expect(preset.getAliasedPath('react-foo')).toBe('react-foo'); - }); - - describe('exact alias', () => { - it('resolves an exact alias', () => { - const preset = createPreset({ - vue$: 'vue/common/dist', - }); - - expect(preset.getAliasedPath('vue')).toBe('vue/common/dist'); - }); - - it("doesnt't resolve a not exact alias", () => { - const preset = createPreset({ - vue$: 'vue/common/dist', - }); - - expect(preset.getAliasedPath('vue/test')).toBe('vue/test'); - }); - }); - }); - }); -}); diff --git a/packages/app/src/sandbox/eval/presets/index.test.ts b/packages/app/src/sandbox/eval/presets/index.test.ts new file mode 100644 index 00000000000..9e95be2a4be --- /dev/null +++ b/packages/app/src/sandbox/eval/presets/index.test.ts @@ -0,0 +1,197 @@ +import Preset from './index'; + +import Transpiler from '../transpilers/index'; +import { LoaderContext } from '../transpiled-module'; + +function createDummyTranspiler(name: string) { + const Klass = class Trans extends Transpiler { + constructor() { + super(name); + } + + doTranspilation(code: string, loaderContext: LoaderContext) { + return Promise.resolve({ transpiledCode: code }); + } + }; + + return new Klass(); +} + +describe('preset', () => { + let evaluator: { + evaluate: jest.Mock; + }; + beforeEach(() => { + evaluator = { + evaluate: jest.fn(), + }; + }); + describe('loaders', () => { + it('tries to resolve loader dynamically if not found', () => { + const preset = new Preset('test', [], {}); + const module = { + path: 'test.js', + code: '', + }; + preset.getLoaders(module, evaluator, '!mdx-loader!'); + expect(evaluator.evaluate).toHaveBeenCalledWith('mdx-loader'); + }); + + it("doesn't use dynamic loader if it's not needed", () => { + const preset = new Preset('test', [], {}); + preset.registerTranspiler(() => false, [ + { transpiler: createDummyTranspiler('babel-loader') }, + ]); + const module = { + path: 'test.js', + code: '', + }; + preset.getLoaders(module, evaluator, '!babel-loader!'); + expect(evaluator.evaluate).not.toHaveBeenCalled(); + }); + }); + + describe('query', () => { + const preset = new Preset('test', [], {}); + + preset.registerTranspiler(t => t.path.endsWith('.js'), [ + { transpiler: createDummyTranspiler('babel-loader') }, + ]); + + preset.registerTranspiler(t => t.path.endsWith('.css'), [ + { transpiler: createDummyTranspiler('style-loader') }, + { transpiler: createDummyTranspiler('modules-loader') }, + ]); + + it('generates the right query for 1 transpiler', () => { + const module = { + path: 'test.js', + code: '', + }; + + expect(preset.getQuery(module, evaluator)).toEqual('!babel-loader'); + }); + + it('generates the right query for 2 transpiler', () => { + const module = { + path: 'test.css', + code: '', + }; + + expect(preset.getQuery(module, evaluator)).toEqual( + '!style-loader!modules-loader' + ); + }); + + it('generates the right query for absolute custom query', () => { + const module = { + path: 'test.css', + code: '', + }; + + expect(preset.getQuery(module, evaluator, '!babel-loader')).toEqual( + '!babel-loader' + ); + }); + + it('generates the right query for custom query', () => { + const module = { + path: 'test.css', + code: '', + }; + + expect(preset.getQuery(module, evaluator, 'babel-loader')).toEqual( + '!style-loader!modules-loader!babel-loader' + ); + }); + }); + + describe('alias', () => { + function createPreset(aliases) { + return new Preset('test', [], aliases); + } + + it('finds the right simple alias', () => { + const preset = createPreset({ + test: 'test2', + }); + + expect(preset.getAliasedPath('test')).toBe('test2'); + }); + + it('chooses the right simple alias', () => { + const preset = createPreset({ + test: 'test2', + testtest: 'test4', + }); + + expect(preset.getAliasedPath('testtest')).toBe('test4'); + }); + + it('works with paths', () => { + const preset = createPreset({ + test: 'test2', + testtest: 'test4', + }); + + expect(preset.getAliasedPath('test/piano/guitar')).toBe( + 'test2/piano/guitar' + ); + }); + + it('works with deeper paths', () => { + const preset = createPreset({ + test: 'test4', + 'test/piano': 'test2', + }); + + expect(preset.getAliasedPath('test/piano/guitar')).toBe('test2/guitar'); + }); + + it('works in a real life scenario', () => { + const preset = createPreset({ + preact$: 'preact', + // preact-compat aliases for supporting React dependencies: + react: 'preact-compat', + 'react-dom': 'preact-compat', + 'create-react-class': 'preact-compat/lib/create-react-class', + 'react-addons-css-transition-group': 'preact-css-transition-group', + }); + + expect(preset.getAliasedPath('react/render')).toBe( + 'preact-compat/render' + ); + }); + + it("doesn't replace partial paths", () => { + const preset = createPreset({ + preact$: 'preact', + // preact-compat aliases for supporting React dependencies: + react: 'preact-compat', + 'react-dom': 'preact-compat', + 'create-react-class': 'preact-compat/lib/create-react-class', + 'react-addons-css-transition-group': 'preact-css-transition-group', + }); + + expect(preset.getAliasedPath('react-foo')).toBe('react-foo'); + }); + + describe('exact alias', () => { + it('resolves an exact alias', () => { + const preset = createPreset({ + vue$: 'vue/common/dist', + }); + + expect(preset.getAliasedPath('vue')).toBe('vue/common/dist'); + }); + + it("doesnt't resolve a not exact alias", () => { + const preset = createPreset({ + vue$: 'vue/common/dist', + }); + + expect(preset.getAliasedPath('vue/test')).toBe('vue/test'); + }); + }); + }); +}); diff --git a/packages/app/src/sandbox/eval/presets/index.ts b/packages/app/src/sandbox/eval/presets/index.ts index e1d047d031f..ad188232690 100644 --- a/packages/app/src/sandbox/eval/presets/index.ts +++ b/packages/app/src/sandbox/eval/presets/index.ts @@ -6,6 +6,8 @@ import { Module } from '../types/module'; import Manager from '../manager'; import Transpiler from '../transpilers'; import TranspiledModule from '../transpiled-module'; +import { WebpackTranspiler } from '../transpilers/webpack'; +import { IEvaluator } from '../evaluator'; type TranspilerDefinition = { transpiler: Transpiler; @@ -193,7 +195,11 @@ export default class Preset { * Get transpilers from the given query, the query is webpack like: * eg. !babel-loader!./test.js */ - getLoaders(module: Module, query: string = ''): Array { + getLoaders( + module: Module, + evaluator: IEvaluator, + query: string = '' + ): Array { const loader = this.loaders.find(t => t.test(module)); // Starting !, drop all transpilers @@ -210,12 +216,15 @@ export default class Preset { .map(loaderName => { const [name, options] = loaderName.split('?'); - const transpiler = Array.from(this.transpilers).find( + let transpiler = Array.from(this.transpilers).find( t => t.name === name ); if (!transpiler) { - throw new Error(`Loader '${name}' could not be found.`); + const webpackLoader = new WebpackTranspiler(name, evaluator); + // If the loader is not installed, we try to run the webpack loader. + this.transpilers.add(webpackLoader); + transpiler = webpackLoader; } const parsedOptions = this.parseOptions(options); @@ -244,8 +253,8 @@ export default class Preset { /** * Get the query syntax of the module */ - getQuery(module: Module, query: string = '') { - const loaders = this.getLoaders(module, query); + getQuery(module: Module, evaluator: IEvaluator, query: string = '') { + const loaders = this.getLoaders(module, evaluator, query); return `!${loaders.map(t => t.transpiler.name).join('!')}`; } diff --git a/packages/app/src/sandbox/eval/transpiled-module.ts b/packages/app/src/sandbox/eval/transpiled-module.ts index 68ef369a0cd..cf53b6610e2 100644 --- a/packages/app/src/sandbox/eval/transpiled-module.ts +++ b/packages/app/src/sandbox/eval/transpiled-module.ts @@ -237,7 +237,7 @@ export default class TranspiledModule { // There are no other modules calling this module, so we run a function on // all transpilers that clears side effects if there are any. Example: // Remove CSS styles from the dom. - manager.preset.getLoaders(this.module, this.query).forEach(t => { + manager.preset.getLoaders(this.module, manager, this.query).forEach(t => { if (t.transpiler.cleanModule) { t.transpiler.cleanModule(this.getLoaderContext(manager, t.options)); } @@ -608,7 +608,11 @@ export default class TranspiledModule { // eslint-disable-next-line code = this.module.code; } else { - const transpilers = manager.preset.getLoaders(this.module, this.query); + const transpilers = manager.preset.getLoaders( + this.module, + manager, + this.query + ); for (let i = 0; i < transpilers.length; i += 1) { const transpilerConfig = transpilers[i]; @@ -678,7 +682,7 @@ export default class TranspiledModule { this.previousSource.compiledCode !== this.source.compiledCode ) { const hasHMR = manager.preset - .getLoaders(this.module, this.query) + .getLoaders(this.module, manager, this.query) .some(t => t.transpiler.HMREnabled == null ? true : t.transpiler.HMREnabled ); @@ -1071,7 +1075,7 @@ export default class TranspiledModule { // For non cacheable transpilers we remove the cached evaluation if ( manager.preset - .getLoaders(this.module, this.query) + .getLoaders(this.module, manager, this.query) .some(t => t.transpiler.cacheable == null ? false : !t.transpiler.cacheable ) diff --git a/packages/app/src/sandbox/eval/transpilers/webpack/index.ts b/packages/app/src/sandbox/eval/transpilers/webpack/index.ts new file mode 100644 index 00000000000..c1c31b5abb5 --- /dev/null +++ b/packages/app/src/sandbox/eval/transpilers/webpack/index.ts @@ -0,0 +1,40 @@ +import { LoaderContext } from 'sandbox/eval/transpiled-module'; +import { IEvaluator } from 'sandbox/eval/evaluator'; + +import Transpiler, { TranspilerResult } from '..'; + +/** + * This is a compatibility loader that acts as bridge between webpack loaders and sandpack + * transpilers. It's a best effort on making webpack loaders work dynamically in Sandpack. + */ +export class WebpackTranspiler extends Transpiler { + private webpackLoader: Promise<(code: string) => string>; + constructor(webpackLoader: string, evaluator: IEvaluator) { + super(webpackLoader); + + this.webpackLoader = evaluator.evaluate(webpackLoader); + } + + async doTranspilation( + code: string, + loaderContext: LoaderContext + ): Promise { + const loader = await this.webpackLoader; + + const asyncFunc = () => (err: Error | null, result: string) => { + if (err) { + throw err; + } + + return result; + }; + + const webpackLoaderContext = { ...loaderContext, async: asyncFunc }; + + const webpackResult = await loader.apply(webpackLoaderContext, [code]); + + return { + transpiledCode: webpackResult, + }; + } +}