diff --git a/packages/babel-plugin-react-native-web/src/__tests__/__snapshots__/index-test.js.snap b/packages/babel-plugin-react-native-web/src/__tests__/__snapshots__/index-test.js.snap index 62166c18f4..3a81278d70 100644 --- a/packages/babel-plugin-react-native-web/src/__tests__/__snapshots__/index-test.js.snap +++ b/packages/babel-plugin-react-native-web/src/__tests__/__snapshots__/index-test.js.snap @@ -1,6 +1,115 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Rewrite react-native to react-native-web export from "react-native": export from "react-native" 1`] = ` +exports[`[commonjs] Rewrite react-native to react-native-web export from "react-native": export from "react-native" 1`] = ` + +export { View } from 'react-native'; +export { StyleSheet, Text, unstable_createElement } from 'react-native'; + + ↓ ↓ ↓ ↓ ↓ ↓ + +export { default as View } from 'react-native-web/dist/cjs/exports/View'; +export { default as StyleSheet } from 'react-native-web/dist/cjs/exports/StyleSheet'; +export { default as Text } from 'react-native-web/dist/cjs/exports/Text'; +export { default as unstable_createElement } from 'react-native-web/dist/cjs/exports/createElement'; + + +`; + +exports[`[commonjs] Rewrite react-native to react-native-web export from "react-native-web": export from "react-native-web" 1`] = ` + +export { View } from 'react-native-web'; +export { StyleSheet, Text, unstable_createElement } from 'react-native-web'; + + ↓ ↓ ↓ ↓ ↓ ↓ + +export { default as View } from 'react-native-web/dist/cjs/exports/View'; +export { default as StyleSheet } from 'react-native-web/dist/cjs/exports/StyleSheet'; +export { default as Text } from 'react-native-web/dist/cjs/exports/Text'; +export { default as unstable_createElement } from 'react-native-web/dist/cjs/exports/createElement'; + + +`; + +exports[`[commonjs] Rewrite react-native to react-native-web import from "react-native": import from "react-native" 1`] = ` + +import ReactNative from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import { Invalid, View as MyView } from 'react-native'; +import { useLocaleContext } from 'react-native'; +import * as ReactNativeModules from 'react-native'; + + ↓ ↓ ↓ ↓ ↓ ↓ + +import ReactNative from 'react-native-web/dist/cjs/index'; +import StyleSheet from 'react-native-web/dist/cjs/exports/StyleSheet'; +import View from 'react-native-web/dist/cjs/exports/View'; +import { Invalid } from 'react-native-web/dist/cjs/index'; +import MyView from 'react-native-web/dist/cjs/exports/View'; +import useLocaleContext from 'react-native-web/dist/cjs/exports/useLocaleContext'; +import * as ReactNativeModules from 'react-native-web/dist/cjs/index'; + + +`; + +exports[`[commonjs] Rewrite react-native to react-native-web import from "react-native-web": import from "react-native-web" 1`] = ` + +import { unstable_createElement } from 'react-native-web'; +import { StyleSheet, View, Pressable, processColor } from 'react-native-web'; +import * as ReactNativeModules from 'react-native-web'; + + ↓ ↓ ↓ ↓ ↓ ↓ + +import unstable_createElement from 'react-native-web/dist/cjs/exports/createElement'; +import StyleSheet from 'react-native-web/dist/cjs/exports/StyleSheet'; +import View from 'react-native-web/dist/cjs/exports/View'; +import Pressable from 'react-native-web/dist/cjs/exports/Pressable'; +import processColor from 'react-native-web/dist/cjs/exports/processColor'; +import * as ReactNativeModules from 'react-native-web/dist/cjs/index'; + + +`; + +exports[`[commonjs] Rewrite react-native to react-native-web require "react-native": require "react-native" 1`] = ` + +const ReactNative = require('react-native'); +const { View } = require('react-native'); +const { StyleSheet, Pressable } = require('react-native'); + + ↓ ↓ ↓ ↓ ↓ ↓ + +const ReactNative = require('react-native-web/dist/cjs/index'); +const View = require('react-native-web/dist/cjs/exports/View').default; +const StyleSheet = + require('react-native-web/dist/cjs/exports/StyleSheet').default; +const Pressable = + require('react-native-web/dist/cjs/exports/Pressable').default; + + +`; + +exports[`[commonjs] Rewrite react-native to react-native-web require "react-native-web": require "react-native-web" 1`] = ` + +const ReactNative = require('react-native-web'); +const { unstable_createElement } = require('react-native-web'); +const { StyleSheet, View, Pressable, processColor } = require('react-native-web'); + + ↓ ↓ ↓ ↓ ↓ ↓ + +const ReactNative = require('react-native-web/dist/cjs/index'); +const unstable_createElement = + require('react-native-web/dist/cjs/exports/createElement').default; +const StyleSheet = + require('react-native-web/dist/cjs/exports/StyleSheet').default; +const View = require('react-native-web/dist/cjs/exports/View').default; +const Pressable = + require('react-native-web/dist/cjs/exports/Pressable').default; +const processColor = + require('react-native-web/dist/cjs/exports/processColor').default; + + +`; + +exports[`[legacy] Rewrite react-native to react-native-web export from "react-native": export from "react-native" 1`] = ` export { View } from 'react-native'; export { StyleSheet, Text, unstable_createElement } from 'react-native'; @@ -15,7 +124,7 @@ export { default as unstable_createElement } from 'react-native-web/dist/exports `; -exports[`Rewrite react-native to react-native-web export from "react-native-web": export from "react-native-web" 1`] = ` +exports[`[legacy] Rewrite react-native to react-native-web export from "react-native-web": export from "react-native-web" 1`] = ` export { View } from 'react-native-web'; export { StyleSheet, Text, unstable_createElement } from 'react-native-web'; @@ -30,10 +139,10 @@ export { default as unstable_createElement } from 'react-native-web/dist/exports `; -exports[`Rewrite react-native to react-native-web import from "react-native": import from "react-native" 1`] = ` +exports[`[legacy] Rewrite react-native to react-native-web import from "react-native": import from "react-native" 1`] = ` import ReactNative from 'react-native'; -import { View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { Invalid, View as MyView } from 'react-native'; import { useLocaleContext } from 'react-native'; import * as ReactNativeModules from 'react-native'; @@ -41,6 +150,7 @@ import * as ReactNativeModules from 'react-native'; ↓ ↓ ↓ ↓ ↓ ↓ import ReactNative from 'react-native-web/dist/index'; +import StyleSheet from 'react-native-web/dist/exports/StyleSheet'; import View from 'react-native-web/dist/exports/View'; import { Invalid } from 'react-native-web/dist/index'; import MyView from 'react-native-web/dist/exports/View'; @@ -50,25 +160,7 @@ import * as ReactNativeModules from 'react-native-web/dist/index'; `; -exports[`Rewrite react-native to react-native-web import from "react-native": import from "react-native" 2`] = ` - -import ReactNative from 'react-native'; -import { View } from 'react-native'; -import { Invalid, View as MyView } from 'react-native'; -import * as ReactNativeModules from 'react-native'; - - ↓ ↓ ↓ ↓ ↓ ↓ - -import ReactNative from 'react-native-web/dist/cjs/index'; -import View from 'react-native-web/dist/cjs/exports/View'; -import { Invalid } from 'react-native-web/dist/cjs/index'; -import MyView from 'react-native-web/dist/cjs/exports/View'; -import * as ReactNativeModules from 'react-native-web/dist/cjs/index'; - - -`; - -exports[`Rewrite react-native to react-native-web import from "react-native-web": import from "react-native-web" 1`] = ` +exports[`[legacy] Rewrite react-native to react-native-web import from "react-native-web": import from "react-native-web" 1`] = ` import { unstable_createElement } from 'react-native-web'; import { StyleSheet, View, Pressable, processColor } from 'react-native-web'; @@ -86,7 +178,7 @@ import * as ReactNativeModules from 'react-native-web/dist/index'; `; -exports[`Rewrite react-native to react-native-web require "react-native": require "react-native" 1`] = ` +exports[`[legacy] Rewrite react-native to react-native-web require "react-native": require "react-native" 1`] = ` const ReactNative = require('react-native'); const { View } = require('react-native'); @@ -102,7 +194,89 @@ const Pressable = require('react-native-web/dist/exports/Pressable').default; `; -exports[`Rewrite react-native to react-native-web require "react-native": require "react-native" 2`] = ` +exports[`[legacy] Rewrite react-native to react-native-web require "react-native-web": require "react-native-web" 1`] = ` + +const ReactNative = require('react-native-web'); +const { unstable_createElement } = require('react-native-web'); +const { StyleSheet, View, Pressable, processColor } = require('react-native-web'); + + ↓ ↓ ↓ ↓ ↓ ↓ + +const ReactNative = require('react-native-web/dist/index'); +const unstable_createElement = + require('react-native-web/dist/exports/createElement').default; +const StyleSheet = require('react-native-web/dist/exports/StyleSheet').default; +const View = require('react-native-web/dist/exports/View').default; +const Pressable = require('react-native-web/dist/exports/Pressable').default; +const processColor = + require('react-native-web/dist/exports/processColor').default; + + +`; + +exports[`Rewrite react-native to react-native-web export from "react-native": export from "react-native" 1`] = ` + +export { View } from 'react-native'; +export { StyleSheet, Text, unstable_createElement } from 'react-native'; + + ↓ ↓ ↓ ↓ ↓ ↓ + +export { View } from 'react-native'; +export { StyleSheet, Text, unstable_createElement } from 'react-native'; + + +`; + +exports[`Rewrite react-native to react-native-web export from "react-native-web": export from "react-native-web" 1`] = ` + +export { View } from 'react-native-web'; +export { StyleSheet, Text, unstable_createElement } from 'react-native-web'; + + ↓ ↓ ↓ ↓ ↓ ↓ + +export { View } from 'react-native-web'; +export { StyleSheet, Text, unstable_createElement } from 'react-native-web'; + + +`; + +exports[`Rewrite react-native to react-native-web import from "react-native": import from "react-native" 1`] = ` + +import ReactNative from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import { Invalid, View as MyView } from 'react-native'; +import { useLocaleContext } from 'react-native'; +import * as ReactNativeModules from 'react-native'; + + ↓ ↓ ↓ ↓ ↓ ↓ + +import ReactNative from 'react-native'; +import StyleSheet from 'react-native-web/dist/exports/StyleSheet/runtime'; +import { View } from 'react-native'; +import { Invalid, View as MyView } from 'react-native'; +import { useLocaleContext } from 'react-native'; +import * as ReactNativeModules from 'react-native'; + + +`; + +exports[`Rewrite react-native to react-native-web import from "react-native-web": import from "react-native-web" 1`] = ` + +import { unstable_createElement } from 'react-native-web'; +import { StyleSheet, View, Pressable, processColor } from 'react-native-web'; +import * as ReactNativeModules from 'react-native-web'; + + ↓ ↓ ↓ ↓ ↓ ↓ + +import { unstable_createElement } from 'react-native-web'; +import StyleSheet from 'react-native-web/dist/exports/StyleSheet/runtime'; +import { View, Pressable, processColor } from 'react-native'; +import * as ReactNativeModules from 'react-native-web'; + + +`; + +exports[`Rewrite react-native to react-native-web require "react-native": require "react-native" 1`] = ` const ReactNative = require('react-native'); const { View } = require('react-native'); @@ -110,12 +284,9 @@ const { StyleSheet, Pressable } = require('react-native'); ↓ ↓ ↓ ↓ ↓ ↓ -const ReactNative = require('react-native-web/dist/cjs/index'); -const View = require('react-native-web/dist/cjs/exports/View').default; -const StyleSheet = - require('react-native-web/dist/cjs/exports/StyleSheet').default; -const Pressable = - require('react-native-web/dist/cjs/exports/Pressable').default; +const ReactNative = require('react-native'); +const { View } = require('react-native'); +const { StyleSheet, Pressable } = require('react-native'); `; @@ -128,14 +299,14 @@ const { StyleSheet, View, Pressable, processColor } = require('react-native-web' ↓ ↓ ↓ ↓ ↓ ↓ -const ReactNative = require('react-native-web/dist/index'); -const unstable_createElement = - require('react-native-web/dist/exports/createElement').default; -const StyleSheet = require('react-native-web/dist/exports/StyleSheet').default; -const View = require('react-native-web/dist/exports/View').default; -const Pressable = require('react-native-web/dist/exports/Pressable').default; -const processColor = - require('react-native-web/dist/exports/processColor').default; +const ReactNative = require('react-native-web'); +const { unstable_createElement } = require('react-native-web'); +const { + StyleSheet, + View, + Pressable, + processColor +} = require('react-native-web'); `; diff --git a/packages/babel-plugin-react-native-web/src/__tests__/index-test.js b/packages/babel-plugin-react-native-web/src/__tests__/index-test.js index 9ef0377913..0f0368c67f 100644 --- a/packages/babel-plugin-react-native-web/src/__tests__/index-test.js +++ b/packages/babel-plugin-react-native-web/src/__tests__/index-test.js @@ -1,69 +1,86 @@ const plugin = require('..'); const pluginTester = require('babel-plugin-tester').default; -const tests = [ - // import react-native - { - title: 'import from "react-native"', - code: `import ReactNative from 'react-native'; -import { View } from 'react-native'; +function createTests(pluginOptions) { + return [ + // import react-native + { + title: 'import from "react-native"', + code: `import ReactNative from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { Invalid, View as MyView } from 'react-native'; import { useLocaleContext } from 'react-native'; import * as ReactNativeModules from 'react-native';`, - snapshot: true - }, - { - title: 'import from "react-native"', - code: `import ReactNative from 'react-native'; -import { View } from 'react-native'; -import { Invalid, View as MyView } from 'react-native'; -import * as ReactNativeModules from 'react-native';`, - snapshot: true, - pluginOptions: { commonjs: true } - }, - { - title: 'import from "react-native-web"', - code: `import { unstable_createElement } from 'react-native-web'; + snapshot: true, + pluginOptions + }, + { + title: 'import from "react-native-web"', + code: `import { unstable_createElement } from 'react-native-web'; import { StyleSheet, View, Pressable, processColor } from 'react-native-web'; import * as ReactNativeModules from 'react-native-web';`, - snapshot: true - }, - { - title: 'export from "react-native"', - code: `export { View } from 'react-native'; + snapshot: true, + pluginOptions + }, + { + title: 'export from "react-native"', + code: `export { View } from 'react-native'; export { StyleSheet, Text, unstable_createElement } from 'react-native';`, - snapshot: true - }, - { - title: 'export from "react-native-web"', - code: `export { View } from 'react-native-web'; + snapshot: true, + pluginOptions + }, + { + title: 'export from "react-native-web"', + code: `export { View } from 'react-native-web'; export { StyleSheet, Text, unstable_createElement } from 'react-native-web';`, - snapshot: true - }, - // require react-native - { - title: 'require "react-native"', - code: `const ReactNative = require('react-native'); + snapshot: true, + pluginOptions + }, + // require react-native + { + title: 'require "react-native"', + code: `const ReactNative = require('react-native'); const { View } = require('react-native'); const { StyleSheet, Pressable } = require('react-native');`, - snapshot: true - }, - { - title: 'require "react-native"', - code: `const ReactNative = require('react-native'); -const { View } = require('react-native'); -const { StyleSheet, Pressable } = require('react-native');`, - snapshot: true, - pluginOptions: { commonjs: true } - }, - { - title: 'require "react-native-web"', - code: `const ReactNative = require('react-native-web'); + snapshot: true, + pluginOptions + }, + { + title: 'require "react-native-web"', + code: `const ReactNative = require('react-native-web'); const { unstable_createElement } = require('react-native-web'); const { StyleSheet, View, Pressable, processColor } = require('react-native-web');`, - snapshot: true - } -]; + snapshot: true, + pluginOptions + } + ]; +} + +pluginTester({ + babelOptions: { + generatorOpts: { + jsescOption: { + quotes: 'single' + } + } + }, + plugin, + pluginName: '[legacy] Rewrite react-native to react-native-web', + tests: createTests({}) +}); + +pluginTester({ + babelOptions: { + generatorOpts: { + jsescOption: { + quotes: 'single' + } + } + }, + plugin, + pluginName: '[commonjs] Rewrite react-native to react-native-web', + tests: createTests({ commonjs: true }) +}); pluginTester({ babelOptions: { @@ -75,5 +92,5 @@ pluginTester({ }, plugin, pluginName: 'Rewrite react-native to react-native-web', - tests + tests: createTests({ legacy: false }) }); diff --git a/packages/babel-plugin-react-native-web/src/index.js b/packages/babel-plugin-react-native-web/src/index.js index 17230a85ab..dcb6533212 100644 --- a/packages/babel-plugin-react-native-web/src/index.js +++ b/packages/babel-plugin-react-native-web/src/index.js @@ -42,36 +42,64 @@ module.exports = function ({ types: t }) { ImportDeclaration(path, state) { const { specifiers } = path.node; if (isReactNativeModule(path.node)) { - const imports = specifiers - .map((specifier) => { - if (t.isImportSpecifier(specifier)) { - const importName = specifier.imported.name; - const distLocation = getDistLocation(importName, state.opts); + if (state.opts.legacy !== false) { + const imports = specifiers + .map((specifier) => { + if (t.isImportSpecifier(specifier)) { + const importName = specifier.imported.name; + const distLocation = getDistLocation(importName, state.opts); - if (distLocation) { - return t.importDeclaration( - [ - t.importDefaultSpecifier( - t.identifier(specifier.local.name) - ) - ], - t.stringLiteral(distLocation) - ); + if (distLocation) { + return t.importDeclaration( + [ + t.importDefaultSpecifier( + t.identifier(specifier.local.name) + ) + ], + t.stringLiteral(distLocation) + ); + } } - } - return t.importDeclaration( - [specifier], - t.stringLiteral(getDistLocation('index', state.opts)) - ); - }) - .filter(Boolean); + return t.importDeclaration( + [specifier], + t.stringLiteral(getDistLocation('index', state.opts)) + ); + }) + .filter(Boolean); - path.replaceWithMultiple(imports); + path.replaceWithMultiple(imports); + } else { + const styleSheetSpecifierIndex = specifiers.findIndex( + (specifier) => + specifier.imported && specifier.imported.name === 'StyleSheet' + ); + if (styleSheetSpecifierIndex !== -1) { + const otherSpecifiers = [ + ...specifiers.slice(0, styleSheetSpecifierIndex), + ...specifiers.slice(styleSheetSpecifierIndex + 1) + ]; + + const newImports = [ + t.importDeclaration( + [t.importDefaultSpecifier(t.identifier('StyleSheet'))], + t.stringLiteral( + 'react-native-web/dist/exports/StyleSheet/runtime' + ) + ), + t.importDeclaration( + otherSpecifiers, + t.stringLiteral('react-native') + ) + ]; + + path.replaceWithMultiple(newImports); + } + } } }, ExportNamedDeclaration(path, state) { const { specifiers } = path.node; - if (isReactNativeModule(path.node)) { + if (isReactNativeModule(path.node) && state.opts.legacy !== false) { const exports = specifiers .map((specifier) => { if (t.isExportSpecifier(specifier)) { @@ -104,7 +132,7 @@ module.exports = function ({ types: t }) { } }, VariableDeclaration(path, state) { - if (isReactNativeRequire(t, path.node)) { + if (isReactNativeRequire(t, path.node) && state.opts.legacy !== false) { const { id } = path.node.declarations[0]; if (t.isObjectPattern(id)) { const imports = id.properties diff --git a/packages/react-native-web-examples/pages/image/index.js b/packages/react-native-web-examples/pages/image/index.js index 086a21a674..623f46c77a 100644 --- a/packages/react-native-web-examples/pages/image/index.js +++ b/packages/react-native-web-examples/pages/image/index.js @@ -15,6 +15,18 @@ const dataBase64Svg = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nMjAwJyBoZWlnaHQ9JzIwMCcgZmlsbD0iIzAwMDAwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiB4PSIwcHgiIHk9IjBweCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDEwMCAxMDAiIHhtbDpzcGFjZT0icHJlc2VydmUiPjxnPjxwYXRoIGQ9Ik0yNS44NjcsNDguODUzQzMyLjgwNiw1MC4xNzYsNDYuNDYsNTIuNSw2MS4yMTUsNTIuNWgwLjAwNWM5LjcxLDAsMTguNDAxLTEuMDU3LDI1LjkzOC0yLjkxMyAgIGMwLjE1OS0wLjA0NiwwLjM1LTAuMTM1LDAuNTY1LTAuMTg3YzAuMjgyLTAuMDcyLDAuNTY1LTAuMTY0LDAuODQ0LTAuMjM4YzMuMTg0LTAuOTY0LDIuNTc3LTMuMDUxLDIuMTk5LTMuODUyICAgYy00LjE2Ni03LjcxOS0xNS4wODYtMjMuNDE1LTM1LjAyOC0yMy40MTVjLTIyLjE2OSwwLTMwLjI2MiwxMC42MzUtMzMuMTQsMTkuNTg5QzIyLjU0NSw0Mi4zMzMsMjIuNDA3LDQ3LjEzNSwyNS44NjcsNDguODUzeiAgICBNMjguNjc2LDM4LjAzMmMwLjAxMy0wLjAzNiwwLjYxNC0xLjYyNiwxLjkyMy0xLjAwOGMxLjEzMywwLjUzNSwwLjk2MSwxLjU2MywwLjg4NywxLjg1Yy0wLjAwNywwLjAyNC0wLjAxNCwwLjA0OC0wLjAyMSwwLjA3MyAgIGMwLDAuMDAxLTAuMDAxLDAuMDA0LTAuMDAxLDAuMDA0bDAsMGMtMC4yNDksMC45MjktMC40MDQsMi4wODYtMC4wMTcsMi44NmMwLjE2LDAuMzE5LDAuNDkyLDAuNzY4LDEuNTQyLDAuOTg3bDAuMzY2LDAuMDc3ICAgYzIwLjgxNiw0LjM2LDM2LDIuOTMzLDQ1LjY3OCwwLjYyNmwtMC4wMDQsMC4wMDJjMCwwLDAuMDA1LTAuMDAyLDAuMDA3LTAuMDAzYzAuMjEyLTAuMDUsMC40MjEtMC4xMDEsMC42MjgtMC4xNTIgICBjMC41MDktMC4wNSwxLjE3MywwLjA3OCwxLjM5OSwxYzAuMzUxLDEuNDI0LTAuOTczLDEuODk1LTEuMjE3LDEuOTY5Yy01LjMyNSwxLjI3OS0xMi4yNjYsMi4zMDYtMjAuODM1LDIuMzA3ICAgYy03LjUwNSwwLTE2LjI1NS0wLjc4Ny0yNi4yNTctMi44ODJsLTAuMzY0LTAuMDc3Yy0yLjEyLTAuNDQyLTMuMTExLTEuNjMzLTMuNTY5LTIuNTU1QzI3Ljk4NSw0MS40MjEsMjguMjgxLDM5LjQxNiwyOC42NzYsMzguMDMyICAgeiI+PC9wYXRoPjxjaXJjbGUgY3g9IjEwLjQ5MyIgY3k9IjIzLjQ1NSIgcj0iMC42MTkiPjwvY2lyY2xlPjxwYXRoIGQ9Ik0yLjA4LDI4LjMwOGMwLjY3Ni0wLjE3OCwwLjk4My0wLjM1MiwxLjE3NC0wLjVDNC42OSwyNi42OSw2LjUsMjcuNDgzLDcuNSwyOC4zNTd2MC4wMDJjMCwwLDEuNzExLDEuMjM1LDAuNzM3LDIuMjAyICAgYy0wLjk3NCwwLjk2NS0yLjMxOSwwLjAwNi0yLjMxOSwwLjAwNmwwLjAzNSwwLjAxNmMtMC4zMjctMC4yMDMtMC42LTAuNTYxLTAuNzgtMC41ODRjLTAuMzcsMC4yNi0wLjg3NiwwLjUtMS40NzYsMC41SDMuNyAgIGMwLDAtMS4zNDUsMC43MDksMC4xNzgsMS42NTJjMC4wMDEsMC4wMDEsMC4wMDIsMC4wNzIsMC4wMDQsMC4wNzNjMy45MzksMi4zNDIsOC4yNzEsNS43MDEsOC4yNzEsOC44OCAgIGMwLDAuNjkxLDAuMiwxNy4wNDIsMTcuNjI2LDI0LjczOWwwLjk2NywwLjQ0MmwtMC4xLDEuMDU5Yy0wLjQyMSw0LjM5LDEuMTQ1LDEwLjE5MSwxMC45OTMsMTIuODg4bDAuMTEzLDAuMDM4ICAgYzAuMDY3LDAuMDIzLDYuNzMyLDIuNDI5LDEwLjkwNywyLjQyOWMxLjU4NCwwLDIuMTU1LTAuMzUyLDIuMjQzLTAuNTYxYzAuMDg1LTAuMjAyLDAuNjEyLTIuMTY0LTYuMzMyLTkuMzg3bDAuMDAyLTAuMTgzICAgYzAsMC0yLjQ3Ny0zLjA3LDEuNTMzLTMuMDdjMC4wMSwwLDAuMDE5LDAsMC4wMjksMGMxLjI4NSwwLDIuNjA4LDAuMjE1LDMuOTgsMC4xODRjNC43NzEtMC4xMTcsOS4zMTYtMC40MjUsMTMuNTA2LTEuMDk2ICAgbDAuNDc0LTAuMDI4bDAuNjY4LDAuMTU4YzkuNjUxLDQuOTQ4LDE2LjczOCw3LjcxNiwxOS43MzgsNy43MTZ2MC4wMDZjMCwwLDAuMTY0LDAuMDExLDAuMjMsMC4wMDQgICBjLTAuMTg5LTAuNzIzLTIuMjMtMi44LTcuMjMtOS4wNzl2MC4wMjFjMCwwLTEuNTEyLTEuNjU4LDAuNzk3LTIuNjUzYzAuMDYzLTAuMDI2LDAuMDA4LDAuMDIzLDAuMDYtMC4wMDEgICBjOC42MzktMy41MDksMTMuNTAxLTguMjA0LDE1LjQxMS0xMS43NzVjMS4xNDUtMi4xMjksMC4yMDYtMi43ODQtMC42NTktMi45NzZjLTAuMzE3LTAuMDM4LTAuNjM0LTAuMDYyLTAuOTEyLTAuMDYyICAgYy0wLjIwNSwwLTAuMzc5LDAuMDEtMC41MjgsMC4wMjdsLTMuMTQzLDEuMjE0QzgzLjczMiw1My45MjYsNzMuMjE4LDU1LjUsNjEuMjIsNTUuNWMtMC4wMDIsMC0wLjAwNSwwLTAuMDA1LDAgICBjLTE1LjEyOCwwLTI5LjEwMS0yLjQzMi0zNi4wODMtMy43NzFsLTAuMTczLTAuMTExbC0wLjE2LTAuMTI2Yy01Ljg1OC0yLjY4MS01LjEzNy0xMC4yMDItNS4xMDMtMTAuNTE5bDAuMDYtMC4zICAgYzAuODk1LTIuODM4LDIuNDY3LTYuMzUyLDUuMjEzLTkuNzE5Yy0xLjgwOC0xLjM2OS00LjU5LTQuMTg4LTQuNDMtOC40OTRjMC4wNDYtMS4yNDQtMC40ODYtMi41MDgtMS40OTgtMy41NTkgICBjLTEuNDk4LTEuNTU1LTMuNzg1LTIuNDQ2LTYuMjc0LTIuNDQ2Yy0xLjc3LDAtMy41NTMsMC40NDItNS4yOTMsMS4zMTRjLTQuMDYxLDIuMDM1LTQuODU1LDQuNzM2LTUuNjkyLDcuNTk2ICAgYy0wLjEzNiwwLjQ2OC0wLjI4NCwwLjkzOS0wLjQzOCwxLjQxYy0wLjAwNiwwLjAxOS0wLjAyMiwwLjAzNS0wLjAyOCwwLjA1NkMwLjgzMywyOC40MjMsMS42OTEsMjguMzksMi4wOCwyOC4zMDh6IE0xMC40OTMsMTkuOTA4ICAgYzEuOTU2LDAsMy41NDgsMS41OTEsMy41NDgsMy41NDdjMCwxLjk1Ny0xLjU5MiwzLjU0OC0zLjU0OCwzLjU0OGMtMS45NTcsMC0zLjU0OC0xLjU5Mi0zLjU0OC0zLjU0OCAgIEM2Ljk0NCwyMS40OTksOC41MzYsMTkuOTA4LDEwLjQ5MywxOS45MDh6Ij48L3BhdGg+PC9nPjwvc3ZnPg=='; const dataSvg = 'data:image/svg+xml;utf8,'; +const sourceWithHeaders = { + uri: placeholder, + headers: { + 'x-token': '0012345' + } +}; +const sourceWithHeadersAndRedirect = { + uri: source, + headers: { + 'x-token': '0012345' + } +}; function Divider() { return ; @@ -118,6 +130,17 @@ export default function ImagePage() { /> + + + + With Headers + + + + Headers & Redirect + + + ); } diff --git a/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap b/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap index 55e2d30ac5..c87e95253f 100644 --- a/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap +++ b/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap @@ -255,7 +255,7 @@ exports[`components/Image prop "source" is correctly updated when missing in ini `; -exports[`components/Image prop "source" is not set immediately if the image has not already been loaded 1`] = ` +exports[`components/Image prop "source" is set immediately if the image has already been loaded 1`] = `
@@ -272,53 +272,53 @@ exports[`components/Image prop "source" is not set immediately if the image has
`; -exports[`components/Image prop "source" is set immediately if the image has already been loaded 1`] = ` +exports[`components/Image prop "source" is set immediately if the image has already been loaded 2`] = `
`; -exports[`components/Image prop "source" is set immediately if the image has already been loaded 2`] = ` +exports[`components/Image prop "source" is set immediately if the image was preloaded 1`] = `
`; -exports[`components/Image prop "source" is set immediately if the image was preloaded 1`] = ` +exports[`components/Image prop "source" is set immediately while image is loading and there is no default source 1`] = `
`; @@ -329,14 +329,14 @@ exports[`components/Image prop "style" removes other unsupported View styles 1`] >
{ beforeEach(() => { ImageUriCache._entries = {}; window.Image = jest.fn(() => ({})); + ImageLoader.load = jest + .fn() + .mockImplementation((source, onLoad, onError) => { + act(() => onLoad({ source })); + }); + ImageLoader.loadWithHeaders = jest.fn().mockImplementation((source) => ({ + source, + promise: Promise.resolve(`blob:${Math.random()}`), + cancel: jest.fn() + })); }); afterEach(() => { @@ -102,10 +112,6 @@ describe('components/Image', () => { describe('prop "onLoad"', () => { test('is called after image is loaded from network', () => { - jest.useFakeTimers(); - ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { - onLoad(); - }); const onLoadStartStub = jest.fn(); const onLoadStub = jest.fn(); const onLoadEndStub = jest.fn(); @@ -117,15 +123,10 @@ describe('components/Image', () => { source="https://test.com/img.jpg" /> ); - jest.runOnlyPendingTimers(); expect(onLoadStub).toBeCalled(); }); test('is called after image is loaded from cache', () => { - jest.useFakeTimers(); - ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { - onLoad(); - }); const onLoadStartStub = jest.fn(); const onLoadStub = jest.fn(); const onLoadEndStub = jest.fn(); @@ -139,7 +140,6 @@ describe('components/Image', () => { source={uri} /> ); - jest.runOnlyPendingTimers(); expect(onLoadStub).toBeCalled(); ImageUriCache.remove(uri); }); @@ -223,6 +223,34 @@ describe('components/Image', () => { }); }); + describe('prop "onLoadStart"', () => { + test('is called on update if "headers" are modified', () => { + const onLoadStartStub = jest.fn(); + const { rerender } = render( + + ); + act(() => { + rerender( + + ); + }); + + expect(onLoadStartStub.mock.calls.length).toBe(2); + }); + }); + describe('prop "resizeMode"', () => { ['contain', 'cover', 'none', 'repeat', 'stretch', undefined].forEach( (resizeMode) => { @@ -241,15 +269,16 @@ describe('components/Image', () => { '', {}, { uri: '' }, - { uri: 'https://google.com' } + { uri: 'https://google.com' }, + { uri: 'https://google.com', headers: { 'x-custom-header': 'abc123' } } ]; sources.forEach((source) => { expect(() => render()).not.toThrow(); }); }); - test('is not set immediately if the image has not already been loaded', () => { - const uri = 'https://google.com/favicon.ico'; + test('is set immediately while image is loading and there is no default source', () => { + const uri = 'https://google.com/not-yet-loaded-image.ico'; const source = { uri }; const { container } = render(); expect(container.firstChild).toMatchSnapshot(); @@ -257,11 +286,6 @@ describe('components/Image', () => { test('is set immediately if the image was preloaded', () => { const uri = 'https://yahoo.com/favicon.ico'; - ImageLoader.load = jest - .fn() - .mockImplementationOnce((_, onLoad, onError) => { - onLoad(); - }); return Image.prefetch(uri).then(() => { const source = { uri }; const { container } = render(, { @@ -342,6 +366,51 @@ describe('components/Image', () => { 'http://localhost/static/img@2x.png' ); }); + + test('it works with headers in 2 stages', async () => { + const uri = 'https://google.com/favicon.ico'; + const headers = { 'x-custom-header': 'abc123' }; + const source = { uri, headers }; + + // Stage 1 + const loadRequest = { + promise: Promise.resolve('blob:123'), + cancel: jest.fn(), + source + }; + + ImageLoader.loadWithHeaders.mockReturnValue(loadRequest); + + render(); + + expect(ImageLoader.loadWithHeaders).toHaveBeenCalledWith( + expect.objectContaining(source) + ); + + // Stage 2 + return waitFor(() => { + expect(ImageLoader.load).toHaveBeenCalledWith( + 'blob:123', + expect.any(Function), + expect.any(Function) + ); + }); + }); + + // A common case is `source` declared as an inline object, which cause is to be a + // new object (with the same content) each time parent component renders + test('it still loads the image if source object is changed', () => { + const uri = 'https://google.com/favicon.ico'; + const headers = { 'x-custom-header': 'abc123' }; + const { rerender } = render(); + rerender(); + + // when the underlying source didn't change we don't expect more than 1 load calls + return waitFor(() => { + expect(ImageLoader.loadWithHeaders).toHaveBeenCalledTimes(1); + expect(ImageLoader.load).toHaveBeenCalledTimes(1); + }); + }); }); describe('prop "style"', () => { diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index bd69e5e844..c89dd0ab3b 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -8,6 +8,7 @@ * @flow */ +import type { ImageSource, LoadRequest } from '../../modules/ImageLoader'; import type { ImageProps } from './types'; import * as React from 'react'; @@ -165,6 +166,23 @@ function resolveAssetUri(source): ?string { return uri; } +function raiseOnErrorEvent(uri, { onError, onLoadEnd }) { + if (onError) { + onError({ + nativeEvent: { + error: `Failed to load resource ${uri} (404)` + } + }); + } + if (onLoadEnd) onLoadEnd(); +} + +function hasSourceDiff(a: ImageSource, b: ImageSource) { + return ( + a.uri !== b.uri || JSON.stringify(a.headers) !== JSON.stringify(b.headers) + ); +} + interface ImageStatics { getSize: ( uri: string, @@ -177,10 +195,12 @@ interface ImageStatics { ) => Promise<{| [uri: string]: 'disk/memory' |}>; } -const Image: React.AbstractComponent< +type ImageComponent = React.AbstractComponent< ImageProps, React.ElementRef -> = React.forwardRef((props, ref) => { +>; + +const BaseImage: ImageComponent = React.forwardRef((props, ref) => { const { 'aria-label': ariaLabel, blurRadius, @@ -205,24 +225,18 @@ const Image: React.AbstractComponent< } } - const [state, updateState] = React.useState(() => { - const uri = resolveAssetUri(source); - if (uri != null) { - const isLoaded = ImageLoader.has(uri); - if (isLoaded) { - return LOADED; - } - } - return IDLE; - }); - + const [state, updateState] = React.useState(IDLE); const [layout, updateLayout] = React.useState({}); const hasTextAncestor = React.useContext(TextAncestorContext); const hiddenImageRef = React.useRef(null); const filterRef = React.useRef(_filterId++); const requestRef = React.useRef(null); + const uri = resolveAssetUri(source); + const isCached = uri != null && ImageLoader.has(uri); const shouldDisplaySource = - state === LOADED || (state === LOADING && defaultSource == null); + state === LOADED || + isCached || + (state === LOADING && defaultSource == null); const [flatStyle, _resizeMode, filter, _tintColor] = getFlatStyle( style, blurRadius, @@ -277,7 +291,6 @@ const Image: React.AbstractComponent< } // Image loading - const uri = resolveAssetUri(source); React.useEffect(() => { abortPendingRequest(); @@ -300,23 +313,14 @@ const Image: React.AbstractComponent< }, function error() { updateState(ERRORED); - if (onError) { - onError({ - nativeEvent: { - error: `Failed to load resource ${uri} (404)` - } - }); - } - if (onLoadEnd) { - onLoadEnd(); - } + raiseOnErrorEvent(uri, { onError, onLoadEnd }); } ); } function abortPendingRequest() { if (requestRef.current != null) { - ImageLoader.abort(requestRef.current); + ImageLoader.clear(requestRef.current); requestRef.current = null; } } @@ -353,14 +357,69 @@ const Image: React.AbstractComponent< ); }); -Image.displayName = 'Image'; +BaseImage.displayName = 'Image'; -// $FlowIgnore: This is the correct type, but casting makes it unhappy since the variables aren't defined yet -const ImageWithStatics = (Image: React.AbstractComponent< - ImageProps, - React.ElementRef -> & - ImageStatics); +/** + * This component handles specifically loading an image source with headers + * default source is never loaded using headers + */ +const ImageWithHeaders: ImageComponent = React.forwardRef((props, ref) => { + // $FlowIgnore: This component would only be rendered when `source` matches `ImageSource` + const nextSource: ImageSource = props.source; + const [blobUri, setBlobUri] = React.useState(''); + const request = React.useRef({ + cancel: () => {}, + source: { uri: '', headers: {} }, + promise: Promise.resolve('') + }); + + const { onLoadStart, ...forwardedProps } = props; + const { onError, onLoadEnd } = forwardedProps; + + React.useEffect(() => { + if (!hasSourceDiff(nextSource, request.current.source)) { + return; + } + + // When source changes we want to clean up any old/running requests + request.current.cancel(); + + if (onLoadStart) { + onLoadStart(); + } + + // Store a ref for the current load request so we know what's the last loaded source, + // and so we can cancel it if a different source is passed through props + request.current = ImageLoader.loadWithHeaders(nextSource); + + request.current.promise + .then((uri) => setBlobUri(uri)) + .catch(() => + raiseOnErrorEvent(request.current.source.uri, { onError, onLoadEnd }) + ); + }, [nextSource, onLoadStart, onError, onLoadEnd]); + + // Cancel any request on unmount + React.useEffect(() => request.current.cancel, []); + + // Until the current component resolves the request (using headers) + // we skip forwarding the source so the base component doesn't attempt + // to load the original source + const source = blobUri ? { ...nextSource, uri: blobUri } : undefined; + + return ; +}); + +// $FlowFixMe +const ImageWithStatics: ImageComponent & ImageStatics = React.forwardRef( + (props, ref) => { + if (props.source && props.source.headers) { + return ; + } + + return ; + } +); ImageWithStatics.getSize = function (uri, success, failure) { ImageLoader.getSize(uri, success, failure); diff --git a/packages/react-native-web/src/exports/StyleSheet/runtime.js b/packages/react-native-web/src/exports/StyleSheet/runtime.js new file mode 100644 index 0000000000..24514ed642 --- /dev/null +++ b/packages/react-native-web/src/exports/StyleSheet/runtime.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) Nicolas Gallagher. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import { localizeStyle } from 'styleq/transform-localize-style'; +import { styleq } from 'styleq'; + +type StyleProps = [string, { [key: string]: mixed } | null]; +type Options = { writingDirection: 'ltr' | 'rtl' }; + +function customStyleq(styles, isRTL) { + return styleq.factory({ + transform(style) { + return localizeStyle(style, isRTL); + } + })(styles); +} + +export default function StyleSheet( + styles: $ReadOnlyArray, + options?: Options +): StyleProps { + const isRTL = options != null && options.writingDirection === 'rtl'; + const styleProps: StyleProps = customStyleq(styles, isRTL); + // inline styles are not processed in any way + return styleProps; +} diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index 892db99292..db887df6d3 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -74,12 +74,13 @@ let id = 0; const requests = {}; const ImageLoader = { - abort(requestId: number) { - let image = requests[`${requestId}`]; + clear(requestId: number) { + const image = requests[`${requestId}`]; if (image) { image.onerror = null; image.onload = null; - image = null; + ImageUriCache.remove(image.src); + image.src = ''; delete requests[`${requestId}`]; } }, @@ -102,7 +103,7 @@ const ImageLoader = { } } if (complete) { - ImageLoader.abort(requestId); + ImageLoader.clear(requestId); clearInterval(interval); } } @@ -111,7 +112,7 @@ const ImageLoader = { if (typeof failure === 'function') { failure(); } - ImageLoader.abort(requestId); + ImageLoader.clear(requestId); clearInterval(interval); } }, @@ -122,9 +123,19 @@ const ImageLoader = { id += 1; const image = new window.Image(); image.onerror = onError; - image.onload = (e) => { + image.onload = (nativeEvent) => { + ImageUriCache.add(uri); // avoid blocking the main thread - const onDecode = () => onLoad({ nativeEvent: e }); + const onDecode = () => { + // Append `source` to match RN's ImageLoadEvent interface + nativeEvent.source = { + uri: image.src, + width: image.naturalWidth, + height: image.naturalHeight + }; + + onLoad({ nativeEvent }); + }; if (typeof image.decode === 'function') { // Safari currently throws exceptions when decoding svgs. // We want to catch that error and allow the load handler @@ -136,16 +147,48 @@ const ImageLoader = { }; image.src = uri; requests[`${id}`] = image; + return id; }, + loadWithHeaders(source: ImageSource): LoadRequest { + let uri: string; + const abortController = new AbortController(); + const request = new Request(source.uri, { + headers: source.headers, + signal: abortController.signal + }); + request.headers.append('accept', 'image/*'); + + const promise = fetch(request) + .then((response) => response.blob()) + .then((blob) => { + uri = URL.createObjectURL(blob); + return uri; + }) + .catch((error) => { + if (error.name === 'AbortError') { + return ''; + } + + throw error; + }); + + return { + promise, + source, + cancel: () => { + abortController.abort(); + URL.revokeObjectURL(uri); + } + }; + }, prefetch(uri: string): Promise { return new Promise((resolve, reject) => { ImageLoader.load( uri, () => { - // Add the uri to the cache so it can be immediately displayed when used - // but also immediately remove it to correctly reflect that it has no active references - ImageUriCache.add(uri); + // load() adds the uri to the cache so it can be immediately displayed when used, + // but we also immediately remove it to correctly reflect that it has no active references ImageUriCache.remove(uri); resolve(); }, @@ -164,4 +207,15 @@ const ImageLoader = { } }; +export type LoadRequest = {| + cancel: Function, + source: ImageSource, + promise: Promise +|}; + +export type ImageSource = { + uri: string, + headers: { [key: string]: string } +}; + export default ImageLoader; diff --git a/packages/react-native-web/src/vendor/react-native/Animated/nodes/AnimatedStyle.js b/packages/react-native-web/src/vendor/react-native/Animated/nodes/AnimatedStyle.js index 1b654319a2..375f960cfc 100644 --- a/packages/react-native-web/src/vendor/react-native/Animated/nodes/AnimatedStyle.js +++ b/packages/react-native-web/src/vendor/react-native/Animated/nodes/AnimatedStyle.js @@ -15,9 +15,18 @@ import AnimatedTransform from './AnimatedTransform'; import AnimatedWithChildren from './AnimatedWithChildren'; import NativeAnimatedHelper from '../NativeAnimatedHelper'; -import StyleSheet from '../../../../exports/StyleSheet'; - -const flattenStyle = StyleSheet.flatten; +function flattenStyle(...styles: any): { [key: string]: any } { + const flatArray = styles.flat(Infinity); + const result = {}; + for (let i = 0; i < flatArray.length; i++) { + const style = flatArray[i]; + if (style != null && typeof style === 'object') { + // $FlowFixMe + Object.assign(result, style); + } + } + return result; +} function createAnimatedStyle(inputStyle: any): Object { const style = flattenStyle(inputStyle);