diff --git a/.babelrc b/.babelrc index 73803b050c..2ff815b425 100644 --- a/.babelrc +++ b/.babelrc @@ -4,6 +4,7 @@ "plugins": [ "fbjs-scripts/babel-6/dev-expression", "syntax-trailing-function-commas", + "babel-plugin-transform-object-rest-spread", "transform-es2015-template-literals", "transform-es2015-literals", "transform-es2015-arrow-functions", diff --git a/.travis.yml b/.travis.yml index bfbdfeaa20..634cf98e55 100644 --- a/.travis.yml +++ b/.travis.yml @@ -58,6 +58,7 @@ script: -F "react-dom-server.min=@build/react-dom-server.min.js" \ -F "npm-react=@build/packages/react.tgz" \ -F "npm-react-dom=@build/packages/react-dom.tgz" \ + -F "npm-react-native=@build/packages/react-native-renderer.tgz" \ -F "commit=$TRAVIS_COMMIT" \ -F "date=`git log --format='%ct' -1`" \ -F "pull_request=$TRAVIS_PULL_REQUEST" \ diff --git a/Gruntfile.js b/Gruntfile.js index c3ee8c20d6..f1f3c43567 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -74,6 +74,10 @@ module.exports = function(grunt) { grunt.registerTask('npm-react-dom:release', npmReactDOMTasks.buildRelease); grunt.registerTask('npm-react-dom:pack', npmReactDOMTasks.packRelease); + var npmReactNativeTasks = require('./grunt/tasks/npm-react-native'); + grunt.registerTask('npm-react-native:release', npmReactNativeTasks.buildRelease); + grunt.registerTask('npm-react-native:pack', npmReactNativeTasks.packRelease); + var npmReactAddonsTasks = require('./grunt/tasks/npm-react-addons'); grunt.registerTask('npm-react-addons:release', npmReactAddonsTasks.buildReleases); grunt.registerTask('npm-react-addons:pack', npmReactAddonsTasks.packReleases); @@ -127,6 +131,8 @@ module.exports = function(grunt) { 'npm-react:pack', 'npm-react-dom:release', 'npm-react-dom:pack', + 'npm-react-native:release', + 'npm-react-native:pack', 'npm-react-addons:release', 'npm-react-addons:pack', 'compare_size', diff --git a/grunt/tasks/npm-react-native.js b/grunt/tasks/npm-react-native.js new file mode 100644 index 0000000000..7157e08f6b --- /dev/null +++ b/grunt/tasks/npm-react-native.js @@ -0,0 +1,46 @@ +'use strict'; + +var fs = require('fs'); +var grunt = require('grunt'); + +var src = 'packages/react-native-renderer/'; +var dest = 'build/packages/react-native-renderer/'; + +function buildRelease() { + if (grunt.file.exists(dest)) { + grunt.file.delete(dest); + } + + // Copy to build/packages/react-native-renderer + var mappings = [].concat( + grunt.file.expandMapping('**/*', dest, {cwd: src}), + grunt.file.expandMapping('{LICENSE,PATENTS}', dest) + ); + mappings.forEach(function(mapping) { + var mappingSrc = mapping.src[0]; + var mappingDest = mapping.dest; + if (grunt.file.isDir(mappingSrc)) { + grunt.file.mkdir(mappingDest); + } else { + grunt.file.copy(mappingSrc, mappingDest); + } + }); +} + +function packRelease() { + var done = this.async(); + var spawnCmd = { + cmd: 'npm', + args: ['pack', 'packages/react-native-renderer'], + }; + grunt.util.spawn(spawnCmd, function() { + var buildSrc = 'react-native-renderer-' + grunt.config.data.pkg.version + '.tgz'; + var buildDest = 'build/packages/react-native-renderer.tgz'; + fs.rename(buildSrc, buildDest, done); + }); +} + +module.exports = { + buildRelease: buildRelease, + packRelease: packRelease, +}; diff --git a/grunt/tasks/version-check.js b/grunt/tasks/version-check.js index ff6da818c0..99f55feac0 100644 --- a/grunt/tasks/version-check.js +++ b/grunt/tasks/version-check.js @@ -11,6 +11,8 @@ module.exports = function() { grunt.file.readJSON('./packages/react/package.json').version, 'packages/react-dom/package.json': grunt.file.readJSON('./packages/react-dom/package.json').version, + 'packages/react-native-renderer/package.json': + grunt.file.readJSON('./packages/react-native-renderer/package.json').version, 'packages/react-addons/package.json (version)': addonsData.version, // Get the "version" without the range bit 'packages/react-addons/package.json (react dependency)': addonsData.peerDependencies.react.slice(1), diff --git a/gulpfile.js b/gulpfile.js index f885ea349a..2c2a7c9823 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -20,6 +20,7 @@ var paths = { react: { src: [ 'src/**/*.js', + '!src/**/__benchmarks__/**/*.js', '!src/**/__tests__/**/*.js', '!src/**/__mocks__/**/*.js', '!src/shared/vendor/**/*.js', @@ -28,17 +29,36 @@ var paths = { }, }; +var fbjsModuleMap = require('fbjs/module-map'); +var moduleMap = {}; +for (var key in fbjsModuleMap) { + moduleMap[key] = fbjsModuleMap[key]; +} +var whiteListNames = [ + 'deepDiffer', + 'deepFreezeAndThrowOnMutationInDev', + 'flattenStyle', + 'InitializeJavaScriptAppEngine', + 'InteractionManager', + 'JSTimersExecution', + 'merge', + 'Platform', + 'RCTEventEmitter', + 'RCTLog', + 'TextInputState', + 'UIManager', + 'View', +]; + +whiteListNames.forEach(function(name) { + moduleMap[name] = name; +}); + +moduleMap['object-assign'] = 'object-assign'; + var babelOpts = { plugins: [ - [babelPluginModules, { - map: Object.assign( - {}, - require('fbjs/module-map'), - { - 'object-assign': 'object-assign', - } - ), - }], + [babelPluginModules, { map: moduleMap }], ], }; diff --git a/package.json b/package.json index 11a716f9cc..c651733353 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "babel-plugin-transform-es2015-template-literals": "^6.5.2", "babel-plugin-transform-es3-member-expression-literals": "^6.5.0", "babel-plugin-transform-es3-property-literals": "^6.5.0", + "babel-plugin-transform-object-rest-spread": "^6.6.5", "babel-preset-react": "^6.5.0", "browserify": "^13.0.0", "bundle-collapser": "^1.1.1", diff --git a/packages/react-native-renderer/README.md b/packages/react-native-renderer/README.md new file mode 100644 index 0000000000..db3e7d3a59 --- /dev/null +++ b/packages/react-native-renderer/README.md @@ -0,0 +1,5 @@ +# `react-native-renderer` + +This package is the renderer that is used by the react-native package. +It is intended to be used inside the react-native environment. It is not +intended to be used stand alone. diff --git a/packages/react-native-renderer/index.js b/packages/react-native-renderer/index.js new file mode 100644 index 0000000000..6f35853e7c --- /dev/null +++ b/packages/react-native-renderer/index.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('react/lib/ReactNative'); diff --git a/packages/react-native-renderer/package.json b/packages/react-native-renderer/package.json new file mode 100644 index 0000000000..a9c487be12 --- /dev/null +++ b/packages/react-native-renderer/package.json @@ -0,0 +1,23 @@ +{ + "name": "react-native-renderer", + "version": "16.0.0-alpha", + "description": "React package for use inside react-native.", + "main": "index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/facebook/react.git" + }, + "keywords": [ + "react", + "react-native" + ], + "license": "BSD-3-Clause", + "bugs": { + "url": "https://github.com/facebook/react/issues" + }, + "homepage": "https://facebook.github.io/react-native/", + "dependencies": { + "fbjs": "^0.8.0", + "react": "^16.0.0-alpha" + } +} diff --git a/packages/react/lib/ReactDOM.native.js b/packages/react/lib/ReactDOM.native.js deleted file mode 100644 index bdd9044a8b..0000000000 --- a/packages/react/lib/ReactDOM.native.js +++ /dev/null @@ -1,12 +0,0 @@ -'use strict'; - -var ReactUpdates = require('./ReactUpdates'); - -// TODO: In React Native, ReactTestUtils depends on ./ReactDOM (for -// renderIntoDocument, which should never be called) and Relay depends on -// react-dom (for batching). Once those are fixed, nothing in RN should import -// this module and this file can go away. - -module.exports = { - unstable_batchedUpdates: ReactUpdates.batchedUpdates, -}; diff --git a/src/renderers/native/ReactIOS/IOSDefaultEventPluginOrder.js b/src/renderers/native/ReactIOS/IOSDefaultEventPluginOrder.js new file mode 100644 index 0000000000..eae211c40f --- /dev/null +++ b/src/renderers/native/ReactIOS/IOSDefaultEventPluginOrder.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule IOSDefaultEventPluginOrder + * @flow + */ +'use strict'; + +var IOSDefaultEventPluginOrder = [ + 'ResponderEventPlugin', + 'IOSNativeBridgeEventPlugin', +]; + +module.exports = IOSDefaultEventPluginOrder; diff --git a/src/renderers/native/ReactIOS/IOSNativeBridgeEventPlugin.js b/src/renderers/native/ReactIOS/IOSNativeBridgeEventPlugin.js new file mode 100644 index 0000000000..564e10f28c --- /dev/null +++ b/src/renderers/native/ReactIOS/IOSNativeBridgeEventPlugin.js @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule IOSNativeBridgeEventPlugin + * @flow + */ +'use strict'; + +var EventPropagators = require('EventPropagators'); +var SyntheticEvent = require('SyntheticEvent'); +var UIManager = require('UIManager'); + +var merge = require('merge'); +var warning = require('warning'); + +var customBubblingEventTypes = UIManager.customBubblingEventTypes; +var customDirectEventTypes = UIManager.customDirectEventTypes; + +var allTypesByEventName = {}; + +for (var bubblingTypeName in customBubblingEventTypes) { + allTypesByEventName[bubblingTypeName] = customBubblingEventTypes[bubblingTypeName]; +} + +for (var directTypeName in customDirectEventTypes) { + warning( + !customBubblingEventTypes[directTypeName], + 'Event cannot be both direct and bubbling: %s', + directTypeName + ); + allTypesByEventName[directTypeName] = customDirectEventTypes[directTypeName]; +} + +var IOSNativeBridgeEventPlugin = { + + eventTypes: merge(customBubblingEventTypes, customDirectEventTypes), + + /** + * @see {EventPluginHub.extractEvents} + */ + extractEvents: function( + topLevelType, + targetInst, + nativeEvent, + nativeEventTarget + ): ?Object { + var bubbleDispatchConfig = customBubblingEventTypes[topLevelType]; + var directDispatchConfig = customDirectEventTypes[topLevelType]; + var event = SyntheticEvent.getPooled( + bubbleDispatchConfig || directDispatchConfig, + targetInst, + nativeEvent, + nativeEventTarget + ); + if (bubbleDispatchConfig) { + EventPropagators.accumulateTwoPhaseDispatches(event); + } else if (directDispatchConfig) { + EventPropagators.accumulateDirectDispatches(event); + } else { + return null; + } + return event; + }, +}; + +module.exports = IOSNativeBridgeEventPlugin; diff --git a/src/renderers/native/ReactIOS/NativeMethodsMixin.js b/src/renderers/native/ReactIOS/NativeMethodsMixin.js new file mode 100644 index 0000000000..0d06e11fef --- /dev/null +++ b/src/renderers/native/ReactIOS/NativeMethodsMixin.js @@ -0,0 +1,219 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule NativeMethodsMixin + * @flow + */ +'use strict'; + +var ReactNativeAttributePayload = require('ReactNativeAttributePayload'); +var TextInputState = require('TextInputState'); +var UIManager = require('UIManager'); + +var findNodeHandle = require('findNodeHandle'); +var invariant = require('invariant'); + +type MeasureOnSuccessCallback = ( + x: number, + y: number, + width: number, + height: number, + pageX: number, + pageY: number +) => void + +type MeasureInWindowOnSuccessCallback = ( + x: number, + y: number, + width: number, + height: number, +) => void + +type MeasureLayoutOnSuccessCallback = ( + left: number, + top: number, + width: number, + height: number +) => void + +function warnForStyleProps(props, validAttributes) { + for (var key in validAttributes.style) { + if (!(validAttributes[key] || props[key] === undefined)) { + console.error( + 'You are setting the style `{ ' + key + ': ... }` as a prop. You ' + + 'should nest it in a style object. ' + + 'E.g. `{ style: { ' + key + ': ... } }`' + ); + } + } +} + +/** + * `NativeMethodsMixin` provides methods to access the underlying native + * component directly. This can be useful in cases when you want to focus + * a view or measure its on-screen dimensions, for example. + * + * The methods described here are available on most of the default components + * provided by React Native. Note, however, that they are *not* available on + * composite components that aren't directly backed by a native view. This will + * generally include most components that you define in your own app. For more + * information, see [Direct + * Manipulation](docs/direct-manipulation.html). + */ +var NativeMethodsMixin = { + /** + * Determines the location on screen, width, and height of the given view and + * returns the values via an async callback. If successful, the callback will + * be called with the following arguments: + * + * - x + * - y + * - width + * - height + * - pageX + * - pageY + * + * Note that these measurements are not available until after the rendering + * has been completed in native. If you need the measurements as soon as + * possible, consider using the [`onLayout` + * prop](docs/view.html#onlayout) instead. + */ + measure: function(callback: MeasureOnSuccessCallback) { + UIManager.measure( + findNodeHandle(this), + mountSafeCallback(this, callback) + ); + }, + + /** + * Determines the location of the given view in the window and returns the + * values via an async callback. If the React root view is embedded in + * another native view, this will give you the absolute coordinates. If + * successful, the callback will be called with the following + * arguments: + * + * - x + * - y + * - width + * - height + * + * Note that these measurements are not available until after the rendering + * has been completed in native. + */ + measureInWindow: function(callback: MeasureInWindowOnSuccessCallback) { + UIManager.measureInWindow( + findNodeHandle(this), + mountSafeCallback(this, callback) + ); + }, + + /** + * Like [`measure()`](#measure), but measures the view relative an ancestor, + * specified as `relativeToNativeNode`. This means that the returned x, y + * are relative to the origin x, y of the ancestor view. + * + * As always, to obtain a native node handle for a component, you can use + * `React.findNodeHandle(component)`. + */ + measureLayout: function( + relativeToNativeNode: number, + onSuccess: MeasureLayoutOnSuccessCallback, + onFail: () => void /* currently unused */ + ) { + UIManager.measureLayout( + findNodeHandle(this), + relativeToNativeNode, + mountSafeCallback(this, onFail), + mountSafeCallback(this, onSuccess) + ); + }, + + /** + * This function sends props straight to native. They will not participate in + * future diff process - this means that if you do not include them in the + * next render, they will remain active (see [Direct + * Manipulation](docs/direct-manipulation.html)). + */ + setNativeProps: function(nativeProps: Object) { + if (__DEV__) { + warnForStyleProps(nativeProps, this.viewConfig.validAttributes); + } + + var updatePayload = ReactNativeAttributePayload.create( + nativeProps, + this.viewConfig.validAttributes + ); + + UIManager.updateView( + findNodeHandle(this), + this.viewConfig.uiViewClassName, + updatePayload + ); + }, + + /** + * Requests focus for the given input or view. The exact behavior triggered + * will depend on the platform and type of view. + */ + focus: function() { + TextInputState.focusTextInput(findNodeHandle(this)); + }, + + /** + * Removes focus from an input or view. This is the opposite of `focus()`. + */ + blur: function() { + TextInputState.blurTextInput(findNodeHandle(this)); + }, +}; + +function throwOnStylesProp(component, props) { + if (props.styles !== undefined) { + var owner = component._owner || null; + var name = component.constructor.displayName; + var msg = '`styles` is not a supported property of `' + name + '`, did ' + + 'you mean `style` (singular)?'; + if (owner && owner.constructor && owner.constructor.displayName) { + msg += '\n\nCheck the `' + owner.constructor.displayName + '` parent ' + + ' component.'; + } + throw new Error(msg); + } +} +if (__DEV__) { + // hide this from Flow since we can't define these properties outside of + // __DEV__ without actually implementing them (setting them to undefined + // isn't allowed by ReactClass) + var NativeMethodsMixin_DEV = (NativeMethodsMixin: any); + invariant( + !NativeMethodsMixin_DEV.componentWillMount && + !NativeMethodsMixin_DEV.componentWillReceiveProps, + 'Do not override existing functions.' + ); + NativeMethodsMixin_DEV.componentWillMount = function() { + throwOnStylesProp(this, this.props); + }; + NativeMethodsMixin_DEV.componentWillReceiveProps = function(newProps) { + throwOnStylesProp(this, newProps); + }; +} + +/** + * In the future, we should cleanup callbacks by cancelling them instead of + * using this. + */ +var mountSafeCallback = function(context: ReactComponent, callback: ?Function): any { + return function() { + if (!callback || (context.isMounted && !context.isMounted())) { + return undefined; + } + return callback.apply(context, arguments); + }; +}; + +module.exports = NativeMethodsMixin; diff --git a/src/renderers/native/ReactNative/ReactNative.js b/src/renderers/native/ReactNative/ReactNative.js new file mode 100644 index 0000000000..e40c6b7576 --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNative.js @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNative + * @flow + */ +'use strict'; + +// Require ReactNativeDefaultInjection first for its side effects of setting up +// the JS environment +var ReactNativeComponentTree = require('ReactNativeComponentTree'); +var ReactNativeDefaultInjection = require('ReactNativeDefaultInjection'); + +var ReactElement = require('ReactElement'); +var ReactNativeMount = require('ReactNativeMount'); +var ReactUpdates = require('ReactUpdates'); + +var findNodeHandle = require('findNodeHandle'); + +ReactNativeDefaultInjection.inject(); + +var render = function( + element: ReactElement, + mountInto: number, + callback?: ?(() => void) +): ?ReactComponent { + return ReactNativeMount.renderComponent(element, mountInto, callback); +}; + +var ReactNative = { + hasReactNativeInitialized: false, + findNodeHandle: findNodeHandle, + render: render, + unmountComponentAtNode: ReactNativeMount.unmountComponentAtNode, + + /* eslint-disable camelcase */ + unstable_batchedUpdates: ReactUpdates.batchedUpdates, + /* eslint-enable camelcase */ + + unmountComponentAtNodeAndRemoveContainer: ReactNativeMount.unmountComponentAtNodeAndRemoveContainer, +}; + +// Inject the runtime into a devtools global hook regardless of browser. +// Allows for debugging when the hook is injected on the page. +/* globals __REACT_DEVTOOLS_GLOBAL_HOOK__ */ +if ( + typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ !== 'undefined' && + typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.inject === 'function') { + __REACT_DEVTOOLS_GLOBAL_HOOK__.inject({ + ComponentTree: { + getClosestInstanceFromNode: function(node) { + return ReactNativeComponentTree.getClosestInstanceFromNode(node); + }, + getNodeFromInstance: function(inst) { + // inst is an internal instance (but could be a composite) + while (inst._renderedComponent) { + inst = inst._renderedComponent; + } + if (inst) { + return ReactNativeComponentTree.getNodeFromInstance(inst); + } else { + return null; + } + }, + }, + Mount: ReactNativeMount, + Reconciler: require('ReactReconciler'), + }); +} + +module.exports = ReactNative; diff --git a/src/renderers/native/ReactNative/ReactNativeAttributePayload.js b/src/renderers/native/ReactNative/ReactNativeAttributePayload.js new file mode 100644 index 0000000000..ca1cd57030 --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeAttributePayload.js @@ -0,0 +1,546 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeAttributePayload + * @flow + */ +'use strict'; + +var Platform = require('Platform'); +var ReactNativePropRegistry = require('ReactNativePropRegistry'); + +var deepDiffer = require('deepDiffer'); +var flattenStyle = require('flattenStyle'); + +var emptyObject = {}; + +/** + * Create a payload that contains all the updates between two sets of props. + * + * These helpers are all encapsulated into a single module, because they use + * mutation as a performance optimization which leads to subtle shared + * dependencies between the code paths. To avoid this mutable state leaking + * across modules, I've kept them isolated to this module. + */ + +type AttributeDiffer = (prevProp: mixed, nextProp: mixed) => boolean; +type AttributePreprocessor = (nextProp: mixed) => mixed; + +type CustomAttributeConfiguration = + { diff: AttributeDiffer, process: AttributePreprocessor } | + { diff: AttributeDiffer } | + { process: AttributePreprocessor }; + +type AttributeConfiguration = + { [key: string]: ( + CustomAttributeConfiguration | AttributeConfiguration /*| boolean*/ + ) }; + +type NestedNode = Array | Object | number; + +// Tracks removed keys +var removedKeys = null; +var removedKeyCount = 0; + +function translateKey(propKey: string) : string { + if (propKey === 'transform') { + // We currently special case the key for `transform`. iOS uses the + // transformMatrix name and Android uses the decomposedMatrix name. + // TODO: We could unify these names and just use the name `transform` + // all the time. Just need to update the native side. + if (Platform.OS === 'android') { + return 'decomposedMatrix'; + } else { + return 'transformMatrix'; + } + } + return propKey; +} + +function defaultDiffer(prevProp: mixed, nextProp: mixed) : boolean { + if (typeof nextProp !== 'object' || nextProp === null) { + // Scalars have already been checked for equality + return true; + } else { + // For objects and arrays, the default diffing algorithm is a deep compare + return deepDiffer(prevProp, nextProp); + } +} + +function resolveObject(idOrObject: number | Object) : Object { + if (typeof idOrObject === 'number') { + return ReactNativePropRegistry.getByID(idOrObject); + } + return idOrObject; +} + +function restoreDeletedValuesInNestedArray( + updatePayload: Object, + node: NestedNode, + validAttributes: AttributeConfiguration +) { + if (Array.isArray(node)) { + var i = node.length; + while (i-- && removedKeyCount > 0) { + restoreDeletedValuesInNestedArray( + updatePayload, + node[i], + validAttributes + ); + } + } else if (node && removedKeyCount > 0) { + var obj = resolveObject(node); + for (var propKey in removedKeys) { + if (!removedKeys[propKey]) { + continue; + } + var nextProp = obj[propKey]; + if (nextProp === undefined) { + continue; + } + + var attributeConfig = validAttributes[propKey]; + if (!attributeConfig) { + continue; // not a valid native prop + } + + if (typeof nextProp === 'function') { + nextProp = true; + } + if (typeof nextProp === 'undefined') { + nextProp = null; + } + + if (typeof attributeConfig !== 'object') { + // case: !Object is the default case + updatePayload[propKey] = nextProp; + } else if (typeof attributeConfig.diff === 'function' || + typeof attributeConfig.process === 'function') { + // case: CustomAttributeConfiguration + var nextValue = typeof attributeConfig.process === 'function' ? + attributeConfig.process(nextProp) : + nextProp; + updatePayload[propKey] = nextValue; + } + removedKeys[propKey] = false; + removedKeyCount--; + } + } +} + +function diffNestedArrayProperty( + updatePayload:? Object, + prevArray: Array, + nextArray: Array, + validAttributes: AttributeConfiguration +) : ?Object { + var minLength = prevArray.length < nextArray.length ? + prevArray.length : + nextArray.length; + var i; + for (i = 0; i < minLength; i++) { + // Diff any items in the array in the forward direction. Repeated keys + // will be overwritten by later values. + updatePayload = diffNestedProperty( + updatePayload, + prevArray[i], + nextArray[i], + validAttributes + ); + } + for (; i < prevArray.length; i++) { + // Clear out all remaining properties. + updatePayload = clearNestedProperty( + updatePayload, + prevArray[i], + validAttributes + ); + } + for (; i < nextArray.length; i++) { + // Add all remaining properties. + updatePayload = addNestedProperty( + updatePayload, + nextArray[i], + validAttributes + ); + } + return updatePayload; +} + +function diffNestedProperty( + updatePayload:? Object, + prevProp: NestedNode, + nextProp: NestedNode, + validAttributes: AttributeConfiguration +) : ?Object { + + if (!updatePayload && prevProp === nextProp) { + // If no properties have been added, then we can bail out quickly on object + // equality. + return updatePayload; + } + + if (!prevProp || !nextProp) { + if (nextProp) { + return addNestedProperty( + updatePayload, + nextProp, + validAttributes + ); + } + if (prevProp) { + return clearNestedProperty( + updatePayload, + prevProp, + validAttributes + ); + } + return updatePayload; + } + + if (!Array.isArray(prevProp) && !Array.isArray(nextProp)) { + // Both are leaves, we can diff the leaves. + return diffProperties( + updatePayload, + resolveObject(prevProp), + resolveObject(nextProp), + validAttributes + ); + } + + if (Array.isArray(prevProp) && Array.isArray(nextProp)) { + // Both are arrays, we can diff the arrays. + return diffNestedArrayProperty( + updatePayload, + prevProp, + nextProp, + validAttributes + ); + } + + if (Array.isArray(prevProp)) { + return diffProperties( + updatePayload, + // $FlowFixMe - We know that this is always an object when the input is. + flattenStyle(prevProp), + // $FlowFixMe - We know that this isn't an array because of above flow. + resolveObject(nextProp), + validAttributes + ); + } + + return diffProperties( + updatePayload, + resolveObject(prevProp), + // $FlowFixMe - We know that this is always an object when the input is. + flattenStyle(nextProp), + validAttributes + ); +} + +/** + * addNestedProperty takes a single set of props and valid attribute + * attribute configurations. It processes each prop and adds it to the + * updatePayload. + */ +function addNestedProperty( + updatePayload:? Object, + nextProp: NestedNode, + validAttributes: AttributeConfiguration +) { + if (!nextProp) { + return updatePayload; + } + + if (!Array.isArray(nextProp)) { + // Add each property of the leaf. + return addProperties( + updatePayload, + resolveObject(nextProp), + validAttributes + ); + } + + for (var i = 0; i < nextProp.length; i++) { + // Add all the properties of the array. + updatePayload = addNestedProperty( + updatePayload, + nextProp[i], + validAttributes + ); + } + + return updatePayload; +} + +/** + * clearNestedProperty takes a single set of props and valid attributes. It + * adds a null sentinel to the updatePayload, for each prop key. + */ +function clearNestedProperty( + updatePayload:? Object, + prevProp: NestedNode, + validAttributes: AttributeConfiguration +) : ?Object { + if (!prevProp) { + return updatePayload; + } + + if (!Array.isArray(prevProp)) { + // Add each property of the leaf. + return clearProperties( + updatePayload, + resolveObject(prevProp), + validAttributes + ); + } + + for (var i = 0; i < prevProp.length; i++) { + // Add all the properties of the array. + updatePayload = clearNestedProperty( + updatePayload, + prevProp[i], + validAttributes + ); + } + return updatePayload; +} + +/** + * diffProperties takes two sets of props and a set of valid attributes + * and write to updatePayload the values that changed or were deleted. + * If no updatePayload is provided, a new one is created and returned if + * anything changed. + */ +function diffProperties( + updatePayload: ?Object, + prevProps: Object, + nextProps: Object, + validAttributes: AttributeConfiguration +): ?Object { + var attributeConfig : ?(CustomAttributeConfiguration | AttributeConfiguration); + var nextProp; + var prevProp; + var altKey; + + for (var propKey in nextProps) { + attributeConfig = validAttributes[propKey]; + if (!attributeConfig) { + continue; // not a valid native prop + } + + altKey = translateKey(propKey); + if (!validAttributes[altKey]) { + // If there is no config for the alternative, bail out. Helps ART. + altKey = propKey; + } + + prevProp = prevProps[propKey]; + nextProp = nextProps[propKey]; + + // functions are converted to booleans as markers that the associated + // events should be sent from native. + if (typeof nextProp === 'function') { + nextProp = (true : any); + // If nextProp is not a function, then don't bother changing prevProp + // since nextProp will win and go into the updatePayload regardless. + if (typeof prevProp === 'function') { + prevProp = (true : any); + } + } + + // An explicit value of undefined is treated as a null because it overrides + // any other preceeding value. + if (typeof nextProp === 'undefined') { + nextProp = (null : any); + if (typeof prevProp === 'undefined') { + prevProp = (null : any); + } + } + + if (removedKeys) { + removedKeys[propKey] = false; + } + + if (updatePayload && updatePayload[altKey] !== undefined) { + // Something else already triggered an update to this key because another + // value diffed. Since we're now later in the nested arrays our value is + // more important so we need to calculate it and override the existing + // value. It doesn't matter if nothing changed, we'll set it anyway. + + // Pattern match on: attributeConfig + if (typeof attributeConfig !== 'object') { + // case: !Object is the default case + updatePayload[altKey] = nextProp; + } else if (typeof attributeConfig.diff === 'function' || + typeof attributeConfig.process === 'function') { + // case: CustomAttributeConfiguration + var nextValue = typeof attributeConfig.process === 'function' ? + attributeConfig.process(nextProp) : + nextProp; + updatePayload[altKey] = nextValue; + } + continue; + } + + if (prevProp === nextProp) { + continue; // nothing changed + } + + // Pattern match on: attributeConfig + if (typeof attributeConfig !== 'object') { + // case: !Object is the default case + if (defaultDiffer(prevProp, nextProp)) { + // a normal leaf has changed + (updatePayload || (updatePayload = {}))[altKey] = nextProp; + } + } else if (typeof attributeConfig.diff === 'function' || + typeof attributeConfig.process === 'function') { + // case: CustomAttributeConfiguration + var shouldUpdate = prevProp === undefined || ( + typeof attributeConfig.diff === 'function' ? + attributeConfig.diff(prevProp, nextProp) : + defaultDiffer(prevProp, nextProp) + ); + if (shouldUpdate) { + nextValue = typeof attributeConfig.process === 'function' ? + attributeConfig.process(nextProp) : + nextProp; + (updatePayload || (updatePayload = {}))[altKey] = nextValue; + } + } else { + // default: fallthrough case when nested properties are defined + removedKeys = null; + removedKeyCount = 0; + updatePayload = diffNestedProperty( + updatePayload, + prevProp, + nextProp, + attributeConfig + ); + if (removedKeyCount > 0 && updatePayload) { + restoreDeletedValuesInNestedArray( + updatePayload, + nextProp, + attributeConfig + ); + removedKeys = null; + } + } + } + + // Also iterate through all the previous props to catch any that have been + // removed and make sure native gets the signal so it can reset them to the + // default. + for (propKey in prevProps) { + if (nextProps[propKey] !== undefined) { + continue; // we've already covered this key in the previous pass + } + attributeConfig = validAttributes[propKey]; + if (!attributeConfig) { + continue; // not a valid native prop + } + + altKey = translateKey(propKey); + if (!attributeConfig[altKey]) { + // If there is no config for the alternative, bail out. Helps ART. + altKey = propKey; + } + + if (updatePayload && updatePayload[altKey] !== undefined) { + // This was already updated to a diff result earlier. + continue; + } + + prevProp = prevProps[propKey]; + if (prevProp === undefined) { + continue; // was already empty anyway + } + // Pattern match on: attributeConfig + if (typeof attributeConfig !== 'object' || + typeof attributeConfig.diff === 'function' || + typeof attributeConfig.process === 'function') { + + // case: CustomAttributeConfiguration | !Object + // Flag the leaf property for removal by sending a sentinel. + (updatePayload || (updatePayload = {}))[altKey] = null; + if (!removedKeys) { + removedKeys = {}; + } + if (!removedKeys[propKey]) { + removedKeys[propKey] = true; + removedKeyCount++; + } + } else { + // default: + // This is a nested attribute configuration where all the properties + // were removed so we need to go through and clear out all of them. + updatePayload = clearNestedProperty( + updatePayload, + prevProp, + attributeConfig + ); + } + } + return updatePayload; +} + +/** + * addProperties adds all the valid props to the payload after being processed. + */ +function addProperties( + updatePayload: ?Object, + props: Object, + validAttributes: AttributeConfiguration +) : ?Object { + // TODO: Fast path + return diffProperties(updatePayload, emptyObject, props, validAttributes); +} + +/** + * clearProperties clears all the previous props by adding a null sentinel + * to the payload for each valid key. + */ +function clearProperties( + updatePayload: ?Object, + prevProps: Object, + validAttributes: AttributeConfiguration +) :? Object { + // TODO: Fast path + return diffProperties(updatePayload, prevProps, emptyObject, validAttributes); +} + +var ReactNativeAttributePayload = { + + create: function( + props: Object, + validAttributes: AttributeConfiguration + ) : ?Object { + return addProperties( + null, // updatePayload + props, + validAttributes + ); + }, + + diff: function( + prevProps: Object, + nextProps: Object, + validAttributes: AttributeConfiguration + ) : ?Object { + return diffProperties( + null, // updatePayload + prevProps, + nextProps, + validAttributes + ); + }, + +}; + +module.exports = ReactNativeAttributePayload; diff --git a/src/renderers/native/ReactNative/ReactNativeBaseComponent.js b/src/renderers/native/ReactNative/ReactNativeBaseComponent.js new file mode 100644 index 0000000000..a15dae5bba --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeBaseComponent.js @@ -0,0 +1,230 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeBaseComponent + * @flow + */ +'use strict'; + +var NativeMethodsMixin = require('NativeMethodsMixin'); +var ReactNativeAttributePayload = require('ReactNativeAttributePayload'); +var ReactNativeComponentTree = require('ReactNativeComponentTree'); +var ReactNativeEventEmitter = require('ReactNativeEventEmitter'); +var ReactNativeTagHandles = require('ReactNativeTagHandles'); +var ReactMultiChild = require('ReactMultiChild'); +var UIManager = require('UIManager'); + +var deepFreezeAndThrowOnMutationInDev = require('deepFreezeAndThrowOnMutationInDev'); + +var registrationNames = ReactNativeEventEmitter.registrationNames; +var putListener = ReactNativeEventEmitter.putListener; +var deleteListener = ReactNativeEventEmitter.deleteListener; +var deleteAllListeners = ReactNativeEventEmitter.deleteAllListeners; + +type ReactNativeBaseComponentViewConfig = { + validAttributes: Object; + uiViewClassName: string; +} + +// require('UIManagerStatTracker').install(); // uncomment to enable + +/** + * @constructor ReactNativeBaseComponent + * @extends ReactComponent + * @extends ReactMultiChild + * @param {!object} UIKit View Configuration. + */ +var ReactNativeBaseComponent = function( + viewConfig: ReactNativeBaseComponentViewConfig +) { + this.viewConfig = viewConfig; +}; + +/** + * Mixin for containers that contain UIViews. NOTE: markup is rendered markup + * which is a `viewID` ... see the return value for `mountComponent` ! + */ +ReactNativeBaseComponent.Mixin = { + getPublicInstance: function() { + // TODO: This should probably use a composite wrapper + return this; + }, + + unmountComponent: function() { + ReactNativeComponentTree.uncacheNode(this); + deleteAllListeners(this); + this.unmountChildren(); + this._rootNodeID = null; + }, + + /** + * Every native component is responsible for allocating its own `tag`, and + * issuing the native `createView` command. But it is not responsible for + * recording the fact that its own `rootNodeID` is associated with a + * `nodeHandle`. Only the code that actually adds its `nodeHandle` (`tag`) as + * a child of a container can confidently record that in + * `ReactNativeTagHandles`. + */ + initializeChildren: function(children, containerTag, transaction, context) { + var mountImages = this.mountChildren(children, transaction, context); + // In a well balanced tree, half of the nodes are in the bottom row and have + // no children - let's avoid calling out to the native bridge for a large + // portion of the children. + if (mountImages.length) { + + // TODO: Pool these per platform view class. Reusing the `mountImages` + // array would likely be a jit deopt. + var createdTags = []; + for (var i = 0, l = mountImages.length; i < l; i++) { + var mountImage = mountImages[i]; + var childTag = mountImage; + createdTags[i] = childTag; + } + UIManager.setChildren(containerTag, createdTags); + } + }, + + /** + * Updates the component's currently mounted representation. + * + * @param {object} nextElement + * @param {ReactReconcileTransaction} transaction + * @param {object} context + * @internal + */ + receiveComponent: function(nextElement, transaction, context) { + var prevElement = this._currentElement; + this._currentElement = nextElement; + + if (__DEV__) { + for (var key in this.viewConfig.validAttributes) { + if (nextElement.props.hasOwnProperty(key)) { + deepFreezeAndThrowOnMutationInDev(nextElement.props[key]); + } + } + } + + var updatePayload = ReactNativeAttributePayload.diff( + prevElement.props, + nextElement.props, + this.viewConfig.validAttributes + ); + + if (updatePayload) { + UIManager.updateView( + this._rootNodeID, + this.viewConfig.uiViewClassName, + updatePayload + ); + } + + this._reconcileListenersUponUpdate( + prevElement.props, + nextElement.props + ); + this.updateChildren(nextElement.props.children, transaction, context); + }, + + /** + * @param {object} initialProps Native component props. + */ + _registerListenersUponCreation: function(initialProps) { + for (var key in initialProps) { + // NOTE: The check for `!props[key]`, is only possible because this method + // registers listeners the *first* time a component is created. + if (registrationNames[key] && initialProps[key]) { + var listener = initialProps[key]; + putListener(this, key, listener); + } + } + }, + + /** + * Reconciles event listeners, adding or removing if necessary. + * @param {object} prevProps Native component props including events. + * @param {object} nextProps Next native component props including events. + */ + _reconcileListenersUponUpdate: function(prevProps, nextProps) { + for (var key in nextProps) { + if (registrationNames[key] && (nextProps[key] !== prevProps[key])) { + if (nextProps[key]) { + putListener(this, key, nextProps[key]); + } else { + deleteListener(this, key); + } + } + } + }, + + /** + * Currently this still uses IDs for reconciliation so this can return null. + * + * @return {null} Null. + */ + getNativeNode: function() { + return this._rootNodeID; + }, + + /** + * @param {string} rootID Root ID of this subtree. + * @param {Transaction} transaction For creating/updating. + * @return {string} Unique iOS view tag. + */ + mountComponent: function(transaction, nativeParent, nativeContainerInfo, context) { + var tag = ReactNativeTagHandles.allocateTag(); + + this._rootNodeID = tag; + this._nativeParent = nativeParent; + this._nativeContainerInfo = nativeContainerInfo; + + if (__DEV__) { + for (var key in this.viewConfig.validAttributes) { + if (this._currentElement.props.hasOwnProperty(key)) { + deepFreezeAndThrowOnMutationInDev(this._currentElement.props[key]); + } + } + } + + var updatePayload = ReactNativeAttributePayload.create( + this._currentElement.props, + this.viewConfig.validAttributes + ); + + var nativeTopRootTag = nativeContainerInfo._tag; + UIManager.createView( + tag, + this.viewConfig.uiViewClassName, + nativeTopRootTag, + updatePayload + ); + + ReactNativeComponentTree.precacheNode(this, tag); + + this._registerListenersUponCreation(this._currentElement.props); + this.initializeChildren( + this._currentElement.props.children, + tag, + transaction, + context + ); + return tag; + }, +}; + +/** + * Order of mixins is important. ReactNativeBaseComponent overrides methods in + * ReactMultiChild. + */ +Object.assign( + ReactNativeBaseComponent.prototype, + ReactMultiChild.Mixin, + ReactNativeBaseComponent.Mixin, + NativeMethodsMixin +); + +module.exports = ReactNativeBaseComponent; diff --git a/src/renderers/native/ReactNative/ReactNativeComponentEnvironment.js b/src/renderers/native/ReactNative/ReactNativeComponentEnvironment.js new file mode 100644 index 0000000000..c8cff21b2e --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeComponentEnvironment.js @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeComponentEnvironment + * @flow + */ +'use strict'; + +var ReactNativeDOMIDOperations = require('ReactNativeDOMIDOperations'); +var ReactNativeReconcileTransaction = require('ReactNativeReconcileTransaction'); + +var ReactNativeComponentEnvironment = { + + processChildrenUpdates: ReactNativeDOMIDOperations.dangerouslyProcessChildrenUpdates, + + replaceNodeWithMarkup: ReactNativeDOMIDOperations.dangerouslyReplaceNodeWithMarkupByID, + + /** + * Nothing to do for UIKit bridge. + * + * @private + */ + unmountIDFromEnvironment: function(/*rootNodeID*/) { + + }, + + /** + * @param {DOMElement} Element to clear. + */ + clearNode: function(/*containerView*/) { + + }, + + ReactReconcileTransaction: ReactNativeReconcileTransaction, +}; + +module.exports = ReactNativeComponentEnvironment; diff --git a/src/renderers/native/ReactNative/ReactNativeComponentTree.js b/src/renderers/native/ReactNative/ReactNativeComponentTree.js new file mode 100644 index 0000000000..82963ad4b1 --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeComponentTree.js @@ -0,0 +1,66 @@ +/** + * Copyright 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeComponentTree + */ + +'use strict'; + +var invariant = require('invariant'); + +var instanceCache = {}; + +/** + * Drill down (through composites and empty components) until we get a native or + * native text component. + * + * This is pretty polymorphic but unavoidable with the current structure we have + * for `_renderedChildren`. + */ +function getRenderedNativeOrTextFromComponent(component) { + var rendered; + while ((rendered = component._renderedComponent)) { + component = rendered; + } + return component; +} + +/** + * Populate `_nativeNode` on the rendered native/text component with the given + * DOM node. The passed `inst` can be a composite. + */ +function precacheNode(inst, tag) { + var nativeInst = getRenderedNativeOrTextFromComponent(inst); + instanceCache[tag] = nativeInst; +} + +function uncacheNode(inst) { + var tag = inst._rootNodeID; + if (tag) { + delete instanceCache[tag]; + } +} + +function getInstanceFromTag(tag) { + return instanceCache[tag] || null; +} + +function getTagFromInstance(inst) { + invariant(inst._rootNodeID, 'All native instances should have a tag.'); + return inst._rootNodeID; +} + +var ReactNativeComponentTree = { + getClosestInstanceFromNode: getInstanceFromTag, + getInstanceFromNode: getInstanceFromTag, + getNodeFromInstance: getTagFromInstance, + precacheNode: precacheNode, + uncacheNode: uncacheNode, +}; + +module.exports = ReactNativeComponentTree; diff --git a/src/renderers/native/ReactNative/ReactNativeContainerInfo.js b/src/renderers/native/ReactNative/ReactNativeContainerInfo.js new file mode 100644 index 0000000000..cc37ede4a0 --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeContainerInfo.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeContainerInfo + * @flow + */ +'use strict'; + +function ReactNativeContainerInfo(tag) { + var info = { + _tag: tag, + }; + return info; +} + +module.exports = ReactNativeContainerInfo; diff --git a/src/renderers/native/ReactNative/ReactNativeDOMIDOperations.js b/src/renderers/native/ReactNative/ReactNativeDOMIDOperations.js new file mode 100644 index 0000000000..0bb92fef55 --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeDOMIDOperations.js @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeDOMIDOperations + * @flow + */ +'use strict'; + +var ReactNativeComponentTree = require('ReactNativeComponentTree'); +var ReactMultiChildUpdateTypes = require('ReactMultiChildUpdateTypes'); +var ReactPerf = require('ReactPerf'); +var UIManager = require('UIManager'); + +/** + * Updates a component's children by processing a series of updates. + * For each of the update/create commands, the `fromIndex` refers to the index + * that the item existed at *before* any of the updates are applied, and the + * `toIndex` refers to the index after *all* of the updates are applied + * (including deletes/moves). TODO: refactor so this can be shared with + * DOMChildrenOperations. + * + * @param {ReactNativeBaseComponent} updates List of update configurations. + * @param {array} markup List of markup strings - in the case of React + * IOS, the ids of new components assumed to be already created. + */ +var dangerouslyProcessChildrenUpdates = function(inst, childrenUpdates) { + if (!childrenUpdates.length) { + return; + } + + var containerTag = ReactNativeComponentTree.getNodeFromInstance(inst); + + var moveFromIndices; + var moveToIndices; + var addChildTags; + var addAtIndices; + var removeAtIndices; + + for (var i = 0; i < childrenUpdates.length; i++) { + var update = childrenUpdates[i]; + if (update.type === ReactMultiChildUpdateTypes.MOVE_EXISTING) { + (moveFromIndices || (moveFromIndices = [])).push(update.fromIndex); + (moveToIndices || (moveToIndices = [])).push(update.toIndex); + } else if (update.type === ReactMultiChildUpdateTypes.REMOVE_NODE) { + (removeAtIndices || (removeAtIndices = [])).push(update.fromIndex); + } else if (update.type === ReactMultiChildUpdateTypes.INSERT_MARKUP) { + var mountImage = update.content; + var tag = mountImage; + (addAtIndices || (addAtIndices = [])).push(update.toIndex); + (addChildTags || (addChildTags = [])).push(tag); + } + } + + UIManager.manageChildren( + containerTag, + moveFromIndices, + moveToIndices, + addChildTags, + addAtIndices, + removeAtIndices + ); +}; + +/** + * Operations used to process updates to DOM nodes. This is made injectable via + * `ReactComponent.DOMIDOperations`. + */ +var ReactNativeDOMIDOperations = { + dangerouslyProcessChildrenUpdates: ReactPerf.measure( + // FIXME(frantic): #4441289 Hack to avoid modifying react-tools + 'ReactDOMIDOperations', + 'dangerouslyProcessChildrenUpdates', + dangerouslyProcessChildrenUpdates + ), + + /** + * Replaces a view that exists in the document with markup. + * + * @param {string} id ID of child to be replaced. + * @param {string} markup Mount image to replace child with id. + */ + dangerouslyReplaceNodeWithMarkupByID: ReactPerf.measure( + 'ReactDOMIDOperations', + 'dangerouslyReplaceNodeWithMarkupByID', + function(id, mountImage) { + var oldTag = id; + UIManager.replaceExistingNonRootView(oldTag, mountImage); + } + ), +}; + +module.exports = ReactNativeDOMIDOperations; diff --git a/src/renderers/native/ReactNative/ReactNativeDefaultInjection.js b/src/renderers/native/ReactNative/ReactNativeDefaultInjection.js new file mode 100644 index 0000000000..2c603ec9aa --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeDefaultInjection.js @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeDefaultInjection + * @flow + */ +'use strict'; + +/** + * Make sure essential globals are available and are patched correctly. Please don't remove this + * line. Bundles created by react-packager `require` it before executing any application code. This + * ensures it exists in the dependency graph and can be `require`d. + */ +require('InitializeJavaScriptAppEngine'); + +var EventPluginHub = require('EventPluginHub'); +var EventPluginUtils = require('EventPluginUtils'); +var IOSDefaultEventPluginOrder = require('IOSDefaultEventPluginOrder'); +var IOSNativeBridgeEventPlugin = require('IOSNativeBridgeEventPlugin'); +var ReactElement = require('ReactElement'); +var ReactComponentEnvironment = require('ReactComponentEnvironment'); +var ReactDefaultBatchingStrategy = require('ReactDefaultBatchingStrategy'); +var ReactEmptyComponent = require('ReactEmptyComponent'); +var ReactNativeComponentEnvironment = require('ReactNativeComponentEnvironment'); +var ReactNativeGlobalInteractionHandler = require('ReactNativeGlobalInteractionHandler'); +var ReactNativeGlobalResponderHandler = require('ReactNativeGlobalResponderHandler'); +var ReactNativeTextComponent = require('ReactNativeTextComponent'); +var ReactNativeTreeTraversal = require('ReactNativeTreeTraversal'); +var ReactNativeComponent = require('ReactNativeComponent'); +var ReactNativeComponentTree = require('ReactNativeComponentTree'); +var ReactSimpleEmptyComponent = require('ReactSimpleEmptyComponent'); +var ReactUpdates = require('ReactUpdates'); +var ResponderEventPlugin = require('ResponderEventPlugin'); + +var invariant = require('invariant'); + +// Just to ensure this gets packaged, since its only caller is from Native. +require('RCTEventEmitter'); +require('RCTLog'); +require('JSTimersExecution'); + +function inject() { + /** + * Inject module for resolving DOM hierarchy and plugin ordering. + */ + EventPluginHub.injection.injectEventPluginOrder(IOSDefaultEventPluginOrder); + EventPluginUtils.injection.injectComponentTree(ReactNativeComponentTree); + EventPluginUtils.injection.injectTreeTraversal(ReactNativeTreeTraversal); + + ResponderEventPlugin.injection.injectGlobalResponderHandler( + ReactNativeGlobalResponderHandler + ); + + ResponderEventPlugin.injection.injectGlobalInteractionHandler( + ReactNativeGlobalInteractionHandler + ); + + /** + * Some important event plugins included by default (without having to require + * them). + */ + EventPluginHub.injection.injectEventPluginsByName({ + 'ResponderEventPlugin': ResponderEventPlugin, + 'IOSNativeBridgeEventPlugin': IOSNativeBridgeEventPlugin, + }); + + ReactUpdates.injection.injectReconcileTransaction( + ReactNativeComponentEnvironment.ReactReconcileTransaction + ); + + ReactUpdates.injection.injectBatchingStrategy( + ReactDefaultBatchingStrategy + ); + + ReactComponentEnvironment.injection.injectEnvironment( + ReactNativeComponentEnvironment + ); + + var EmptyComponent = (instantiate) => { + // Can't import View at the top because it depends on React to make its composite + var View = require('View'); + return new ReactSimpleEmptyComponent( + ReactElement.createElement(View, { + collapsable: true, + style: { position: 'absolute' }, + }), + instantiate + ); + }; + + ReactEmptyComponent.injection.injectEmptyComponentFactory(EmptyComponent); + + ReactNativeComponent.injection.injectTextComponentClass( + ReactNativeTextComponent + ); + ReactNativeComponent.injection.injectGenericComponentClass(function(tag) { + // Show a nicer error message for non-function tags + var info = ''; + if (typeof tag === 'string' && /^[a-z]/.test(tag)) { + info += ' Each component name should start with an uppercase letter.'; + } + invariant(false, 'Expected a component class, got %s.%s', tag, info); + }); +} + +module.exports = { + inject: inject, +}; diff --git a/src/renderers/native/ReactNative/ReactNativeEventEmitter.js b/src/renderers/native/ReactNative/ReactNativeEventEmitter.js new file mode 100644 index 0000000000..1b252c6995 --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeEventEmitter.js @@ -0,0 +1,215 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeEventEmitter + * @flow + */ +'use strict'; + +var EventPluginHub = require('EventPluginHub'); +var EventPluginRegistry = require('EventPluginRegistry'); +var ReactEventEmitterMixin = require('ReactEventEmitterMixin'); +var ReactNativeComponentTree = require('ReactNativeComponentTree'); +var ReactNativeTagHandles = require('ReactNativeTagHandles'); +var EventConstants = require('EventConstants'); + +var merge = require('merge'); +var warning = require('warning'); + +var topLevelTypes = EventConstants.topLevelTypes; + +/** + * Version of `ReactBrowserEventEmitter` that works on the receiving side of a + * serialized worker boundary. + */ + +// Shared default empty native event - conserve memory. +var EMPTY_NATIVE_EVENT = {}; + +/** + * Selects a subsequence of `Touch`es, without destroying `touches`. + * + * @param {Array} touches Deserialized touch objects. + * @param {Array} indices Indices by which to pull subsequence. + * @return {Array} Subsequence of touch objects. + */ +var touchSubsequence = function(touches, indices) { + var ret = []; + for (var i = 0; i < indices.length; i++) { + ret.push(touches[indices[i]]); + } + return ret; +}; + +/** + * TODO: Pool all of this. + * + * Destroys `touches` by removing touch objects at indices `indices`. This is + * to maintain compatibility with W3C touch "end" events, where the active + * touches don't include the set that has just been "ended". + * + * @param {Array} touches Deserialized touch objects. + * @param {Array} indices Indices to remove from `touches`. + * @return {Array} Subsequence of removed touch objects. + */ +var removeTouchesAtIndices = function( + touches: Array, + indices: Array +): Array { + var rippedOut = []; + // use an unsafe downcast to alias to nullable elements, + // so we can delete and then compact. + var temp: Array = (touches: Array); + for (var i = 0; i < indices.length; i++) { + var index = indices[i]; + rippedOut.push(touches[index]); + temp[index] = null; + } + var fillAt = 0; + for (var j = 0; j < temp.length; j++) { + var cur = temp[j]; + if (cur !== null) { + temp[fillAt++] = cur; + } + } + temp.length = fillAt; + return rippedOut; +}; + +/** + * `ReactNativeEventEmitter` is used to attach top-level event listeners. For example: + * + * ReactNativeEventEmitter.putListener('myID', 'onClick', myFunction); + * + * This would allocate a "registration" of `('onClick', myFunction)` on 'myID'. + * + * @internal + */ +var ReactNativeEventEmitter = merge(ReactEventEmitterMixin, { + + registrationNames: EventPluginRegistry.registrationNameModules, + + putListener: EventPluginHub.putListener, + + getListener: EventPluginHub.getListener, + + deleteListener: EventPluginHub.deleteListener, + + deleteAllListeners: EventPluginHub.deleteAllListeners, + + /** + * Internal version of `receiveEvent` in terms of normalized (non-tag) + * `rootNodeID`. + * + * @see receiveEvent. + * + * @param {rootNodeID} rootNodeID React root node ID that event occurred on. + * @param {TopLevelType} topLevelType Top level type of event. + * @param {object} nativeEventParam Object passed from native. + */ + _receiveRootNodeIDEvent: function( + rootNodeID: number, + topLevelType: string, + nativeEventParam: Object + ) { + var nativeEvent = nativeEventParam || EMPTY_NATIVE_EVENT; + var inst = ReactNativeComponentTree.getInstanceFromNode(rootNodeID); + ReactNativeEventEmitter.handleTopLevel( + topLevelType, + inst, + nativeEvent, + nativeEvent.target + ); + }, + + /** + * Publicly exposed method on module for native objc to invoke when a top + * level event is extracted. + * @param {rootNodeID} rootNodeID React root node ID that event occurred on. + * @param {TopLevelType} topLevelType Top level type of event. + * @param {object} nativeEventParam Object passed from native. + */ + receiveEvent: function( + tag: number, + topLevelType: string, + nativeEventParam: Object + ) { + var rootNodeID = tag; + ReactNativeEventEmitter._receiveRootNodeIDEvent( + rootNodeID, + topLevelType, + nativeEventParam + ); + }, + + /** + * Simple multi-wrapper around `receiveEvent` that is intended to receive an + * efficient representation of `Touch` objects, and other information that + * can be used to construct W3C compliant `Event` and `Touch` lists. + * + * This may create dispatch behavior that differs than web touch handling. We + * loop through each of the changed touches and receive it as a single event. + * So two `touchStart`/`touchMove`s that occur simultaneously are received as + * two separate touch event dispatches - when they arguably should be one. + * + * This implementation reuses the `Touch` objects themselves as the `Event`s + * since we dispatch an event for each touch (though that might not be spec + * compliant). The main purpose of reusing them is to save allocations. + * + * TODO: Dispatch multiple changed touches in one event. The bubble path + * could be the first common ancestor of all the `changedTouches`. + * + * One difference between this behavior and W3C spec: cancelled touches will + * not appear in `.touches`, or in any future `.touches`, though they may + * still be "actively touching the surface". + * + * Web desktop polyfills only need to construct a fake touch event with + * identifier 0, also abandoning traditional click handlers. + */ + receiveTouches: function( + eventTopLevelType: string, + touches: Array, + changedIndices: Array + ) { + var changedTouches = + eventTopLevelType === topLevelTypes.topTouchEnd || + eventTopLevelType === topLevelTypes.topTouchCancel ? + removeTouchesAtIndices(touches, changedIndices) : + touchSubsequence(touches, changedIndices); + + for (var jj = 0; jj < changedTouches.length; jj++) { + var touch = changedTouches[jj]; + // Touch objects can fulfill the role of `DOM` `Event` objects if we set + // the `changedTouches`/`touches`. This saves allocations. + touch.changedTouches = changedTouches; + touch.touches = touches; + var nativeEvent = touch; + var rootNodeID = null; + var target = nativeEvent.target; + if (target !== null && target !== undefined) { + if (target < ReactNativeTagHandles.tagsStartAt) { + if (__DEV__) { + warning( + false, + 'A view is reporting that a touch occured on tag zero.' + ); + } + } else { + rootNodeID = target; + } + } + ReactNativeEventEmitter._receiveRootNodeIDEvent( + rootNodeID, + eventTopLevelType, + nativeEvent + ); + } + }, +}); + +module.exports = ReactNativeEventEmitter; diff --git a/src/renderers/native/ReactNative/ReactNativeGlobalInteractionHandler.js b/src/renderers/native/ReactNative/ReactNativeGlobalInteractionHandler.js new file mode 100644 index 0000000000..b84408a0d5 --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeGlobalInteractionHandler.js @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeGlobalInteractionHandler + * @flow + */ +'use strict'; + +var InteractionManager = require('InteractionManager'); + +// Interaction handle is created/cleared when responder is granted or +// released/terminated. +var interactionHandle = null; + +var ReactNativeGlobalInteractionHandler = { + onChange: function(numberActiveTouches: number) { + if (numberActiveTouches === 0) { + if (interactionHandle) { + InteractionManager.clearInteractionHandle(interactionHandle); + interactionHandle = null; + } + } else if (!interactionHandle) { + interactionHandle = InteractionManager.createInteractionHandle(); + } + }, +}; + +module.exports = ReactNativeGlobalInteractionHandler; diff --git a/src/renderers/native/ReactNative/ReactNativeGlobalResponderHandler.js b/src/renderers/native/ReactNative/ReactNativeGlobalResponderHandler.js new file mode 100644 index 0000000000..84bd45fb6f --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeGlobalResponderHandler.js @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeGlobalResponderHandler + */ +'use strict'; + +var UIManager = require('UIManager'); + +var ReactNativeGlobalResponderHandler = { + onChange: function(from, to, blockNativeResponder) { + if (to !== null) { + UIManager.setJSResponder( + to._rootNodeID, + blockNativeResponder + ); + } else { + UIManager.clearJSResponder(); + } + }, +}; + +module.exports = ReactNativeGlobalResponderHandler; diff --git a/src/renderers/native/ReactNative/ReactNativeMount.js b/src/renderers/native/ReactNative/ReactNativeMount.js new file mode 100644 index 0000000000..50d300923a --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeMount.js @@ -0,0 +1,238 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeMount + * @flow + */ +'use strict'; + +var ReactElement = require('ReactElement'); +var ReactNativeContainerInfo = require('ReactNativeContainerInfo'); +var ReactNativeTagHandles = require('ReactNativeTagHandles'); +var ReactPerf = require('ReactPerf'); +var ReactReconciler = require('ReactReconciler'); +var ReactUpdateQueue = require('ReactUpdateQueue'); +var ReactUpdates = require('ReactUpdates'); +var UIManager = require('UIManager'); + +var emptyObject = require('emptyObject'); +var instantiateReactComponent = require('instantiateReactComponent'); +var shouldUpdateReactComponent = require('shouldUpdateReactComponent'); + +/** + * Temporary (?) hack so that we can store all top-level pending updates on + * composites instead of having to worry about different types of components + * here. + */ +var TopLevelWrapper = function() {}; +TopLevelWrapper.prototype.isReactComponent = {}; +if (__DEV__) { + TopLevelWrapper.displayName = 'TopLevelWrapper'; +} +TopLevelWrapper.prototype.render = function() { + // this.props is actually a ReactElement + return this.props; +}; + +/** + * Mounts this component and inserts it into the DOM. + * + * @param {ReactComponent} componentInstance The instance to mount. + * @param {number} rootID ID of the root node. + * @param {number} containerTag container element to mount into. + * @param {ReactReconcileTransaction} transaction + */ +function mountComponentIntoNode( + componentInstance, + containerTag, + transaction) { + var markup = ReactReconciler.mountComponent( + componentInstance, + transaction, + null, + ReactNativeContainerInfo(containerTag), + emptyObject + ); + componentInstance._renderedComponent._topLevelWrapper = componentInstance; + ReactNativeMount._mountImageIntoNode(markup, containerTag); +} + +/** + * Batched mount. + * + * @param {ReactComponent} componentInstance The instance to mount. + * @param {number} rootID ID of the root node. + * @param {number} containerTag container element to mount into. + */ +function batchedMountComponentIntoNode( + componentInstance, + containerTag) { + var transaction = ReactUpdates.ReactReconcileTransaction.getPooled(); + transaction.perform( + mountComponentIntoNode, + null, + componentInstance, + containerTag, + transaction + ); + ReactUpdates.ReactReconcileTransaction.release(transaction); +} + +/** + * As soon as `ReactMount` is refactored to not rely on the DOM, we can share + * code between the two. For now, we'll hard code the ID logic. + */ +var ReactNativeMount = { + _instancesByContainerID: {}, + + // these two functions are needed by React Devtools + findNodeHandle: require('findNodeHandle'), + + /** + * @param {ReactComponent} instance Instance to render. + * @param {containerTag} containerView Handle to native view tag + */ + renderComponent: function( + nextElement: ReactElement, + containerTag: number, + callback?: ?(() => void) + ): ?ReactComponent { + var nextWrappedElement = new ReactElement( + TopLevelWrapper, + null, + null, + null, + null, + null, + nextElement + ); + + var topRootNodeID = containerTag; + var prevComponent = ReactNativeMount._instancesByContainerID[topRootNodeID]; + if (prevComponent) { + var prevWrappedElement = prevComponent._currentElement; + var prevElement = prevWrappedElement.props; + if (shouldUpdateReactComponent(prevElement, nextElement)) { + ReactUpdateQueue.enqueueElementInternal(prevComponent, nextWrappedElement); + if (callback) { + ReactUpdateQueue.enqueueCallbackInternal(prevComponent, callback); + } + return prevComponent; + } else { + ReactNativeMount.unmountComponentAtNode(containerTag); + } + } + + if (!ReactNativeTagHandles.reactTagIsNativeTopRootID(containerTag)) { + console.error('You cannot render into anything but a top root'); + return null; + } + + ReactNativeTagHandles.assertRootTag(containerTag); + + var instance = instantiateReactComponent(nextWrappedElement); + ReactNativeMount._instancesByContainerID[containerTag] = instance; + + // The initial render is synchronous but any updates that happen during + // rendering, in componentWillMount or componentDidMount, will be batched + // according to the current batching strategy. + + ReactUpdates.batchedUpdates( + batchedMountComponentIntoNode, + instance, + containerTag + ); + var component = instance.getPublicInstance(); + if (callback) { + callback.call(component); + } + return component; + }, + + /** + * @param {View} view View tree image. + * @param {number} containerViewID View to insert sub-view into. + */ + _mountImageIntoNode: ReactPerf.measure( + // FIXME(frantic): #4441289 Hack to avoid modifying react-tools + 'ReactComponentBrowserEnvironment', + 'mountImageIntoNode', + function(mountImage, containerID) { + // Since we now know that the `mountImage` has been mounted, we can + // mark it as such. + var childTag = mountImage; + UIManager.setChildren( + containerID, + [childTag] + ); + } + ), + + /** + * Standard unmounting of the component that is rendered into `containerID`, + * but will also execute a command to remove the actual container view + * itself. This is useful when a client is cleaning up a React tree, and also + * knows that the container will no longer be needed. When executing + * asynchronously, it's easier to just have this method be the one that calls + * for removal of the view. + */ + unmountComponentAtNodeAndRemoveContainer: function( + containerTag: number + ) { + ReactNativeMount.unmountComponentAtNode(containerTag); + // call back into native to remove all of the subviews from this container + UIManager.removeRootView(containerTag); + }, + + /** + * Unmount component at container ID by iterating through each child component + * that has been rendered and unmounting it. There should just be one child + * component at this time. + */ + unmountComponentAtNode: function(containerTag: number): boolean { + if (!ReactNativeTagHandles.reactTagIsNativeTopRootID(containerTag)) { + console.error('You cannot render into anything but a top root'); + return false; + } + + var instance = ReactNativeMount._instancesByContainerID[containerTag]; + if (!instance) { + return false; + } + ReactNativeMount.unmountComponentFromNode(instance, containerTag); + delete ReactNativeMount._instancesByContainerID[containerTag]; + return true; + }, + + /** + * Unmounts a component and sends messages back to iOS to remove its subviews. + * + * @param {ReactComponent} instance React component instance. + * @param {string} containerID ID of container we're removing from. + * @final + * @internal + * @see {ReactNativeMount.unmountComponentAtNode} + */ + unmountComponentFromNode: function( + instance: ReactComponent, + containerID: string + ) { + // Call back into native to remove all of the subviews from this container + ReactReconciler.unmountComponent(instance); + UIManager.removeSubviewsFromContainerWithID(containerID); + }, + +}; + +ReactNativeMount.renderComponent = ReactPerf.measure( + 'ReactMount', + '_renderNewRootComponent', + ReactNativeMount.renderComponent +); + +module.exports = ReactNativeMount; diff --git a/src/renderers/native/ReactNative/ReactNativePropRegistry.js b/src/renderers/native/ReactNative/ReactNativePropRegistry.js new file mode 100644 index 0000000000..3cffb9acf3 --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativePropRegistry.js @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativePropRegistry + * @flow + */ +'use strict'; + +var objects = {}; +var uniqueID = 1; +var emptyObject = {}; + +class ReactNativePropRegistry { + static register(object: Object): number { + var id = ++uniqueID; + if (__DEV__) { + Object.freeze(object); + } + objects[id] = object; + return id; + } + + static getByID(id: number): Object { + if (!id) { + // Used in the style={[condition && id]} pattern, + // we want it to be a no-op when the value is false or null + return emptyObject; + } + + var object = objects[id]; + if (!object) { + console.warn('Invalid style with id `' + id + '`. Skipping ...'); + return emptyObject; + } + return object; + } +} + +module.exports = ReactNativePropRegistry; diff --git a/src/renderers/native/ReactNative/ReactNativeReconcileTransaction.js b/src/renderers/native/ReactNative/ReactNativeReconcileTransaction.js new file mode 100644 index 0000000000..1c40be74bc --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeReconcileTransaction.js @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeReconcileTransaction + * @flow + */ +'use strict'; + +var CallbackQueue = require('CallbackQueue'); +var PooledClass = require('PooledClass'); +var Transaction = require('Transaction'); + +/** + * Provides a `CallbackQueue` queue for collecting `onDOMReady` callbacks during + * the performing of the transaction. + */ +var ON_DOM_READY_QUEUEING = { + /** + * Initializes the internal `onDOMReady` queue. + */ + initialize: function() { + this.reactMountReady.reset(); + }, + + /** + * After DOM is flushed, invoke all registered `onDOMReady` callbacks. + */ + close: function() { + this.reactMountReady.notifyAll(); + }, +}; + +/** + * Executed within the scope of the `Transaction` instance. Consider these as + * being member methods, but with an implied ordering while being isolated from + * each other. + */ +var TRANSACTION_WRAPPERS = [ON_DOM_READY_QUEUEING]; + +/** + * Currently: + * - The order that these are listed in the transaction is critical: + * - Suppresses events. + * - Restores selection range. + * + * Future: + * - Restore document/overflow scroll positions that were unintentionally + * modified via DOM insertions above the top viewport boundary. + * - Implement/integrate with customized constraint based layout system and keep + * track of which dimensions must be remeasured. + * + * @class ReactNativeReconcileTransaction + */ +function ReactNativeReconcileTransaction() { + this.reinitializeTransaction(); + this.reactMountReady = CallbackQueue.getPooled(null); +} + +var Mixin = { + /** + * @see Transaction + * @abstract + * @final + * @return {array} List of operation wrap procedures. + * TODO: convert to array + */ + getTransactionWrappers: function() { + return TRANSACTION_WRAPPERS; + }, + + /** + * @return {object} The queue to collect `onDOMReady` callbacks with. + * TODO: convert to ReactMountReady + */ + getReactMountReady: function() { + return this.reactMountReady; + }, + + /** + * `PooledClass` looks for this, and will invoke this before allowing this + * instance to be reused. + */ + destructor: function() { + CallbackQueue.release(this.reactMountReady); + this.reactMountReady = null; + }, +}; + +Object.assign( + ReactNativeReconcileTransaction.prototype, + Transaction.Mixin, + ReactNativeReconcileTransaction, + Mixin +); + +PooledClass.addPoolingTo(ReactNativeReconcileTransaction); + +module.exports = ReactNativeReconcileTransaction; diff --git a/src/renderers/native/ReactNative/ReactNativeTagHandles.js b/src/renderers/native/ReactNative/ReactNativeTagHandles.js new file mode 100644 index 0000000000..2a4d7bd010 --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeTagHandles.js @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeTagHandles + * @flow + */ +'use strict'; + +var invariant = require('invariant'); + +/** + * Keeps track of allocating and associating native "tags" which are numeric, + * unique view IDs. All the native tags are negative numbers, to avoid + * collisions, but in the JS we keep track of them as positive integers to store + * them effectively in Arrays. So we must refer to them as "inverses" of the + * native tags (that are * normally negative). + * + * It *must* be the case that every `rootNodeID` always maps to the exact same + * `tag` forever. The easiest way to accomplish this is to never delete + * anything from this table. + * Why: Because `dangerouslyReplaceNodeWithMarkupByID` relies on being able to + * unmount a component with a `rootNodeID`, then mount a new one in its place, + */ +var INITIAL_TAG_COUNT = 1; +var ReactNativeTagHandles = { + tagsStartAt: INITIAL_TAG_COUNT, + tagCount: INITIAL_TAG_COUNT, + + allocateTag: function(): number { + // Skip over root IDs as those are reserved for native + while (this.reactTagIsNativeTopRootID(ReactNativeTagHandles.tagCount)) { + ReactNativeTagHandles.tagCount++; + } + var tag = ReactNativeTagHandles.tagCount; + ReactNativeTagHandles.tagCount++; + return tag; + }, + + assertRootTag: function(tag: number): void { + invariant( + this.reactTagIsNativeTopRootID(tag), + 'Expect a native root tag, instead got %s', tag + ); + }, + + reactTagIsNativeTopRootID: function(reactTag: number): bool { + // We reserve all tags that are 1 mod 10 for native root views + return reactTag % 10 === 1; + }, +}; + +module.exports = ReactNativeTagHandles; diff --git a/src/renderers/native/ReactNative/ReactNativeTextComponent.js b/src/renderers/native/ReactNative/ReactNativeTextComponent.js new file mode 100644 index 0000000000..3de8a82dd2 --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeTextComponent.js @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeTextComponent + */ + +'use strict'; + +var ReactNativeComponentTree = require('ReactNativeComponentTree'); +var ReactNativeTagHandles = require('ReactNativeTagHandles'); +var UIManager = require('UIManager'); + +var invariant = require('invariant'); + +var ReactNativeTextComponent = function(text) { + // This is really a ReactText (ReactNode), not a ReactElement + this._currentElement = text; + this._stringText = '' + text; + this._nativeParent = null; + this._rootNodeID = null; +}; + +Object.assign(ReactNativeTextComponent.prototype, { + + mountComponent: function(transaction, nativeParent, nativeContainerInfo, context) { + // TODO: nativeParent should have this context already. Stop abusing context. + invariant( + context.isInAParentText, + 'RawText "%s" must be wrapped in an explicit component.', + this._stringText + ); + this._nativeParent = nativeParent; + var tag = ReactNativeTagHandles.allocateTag(); + this._rootNodeID = tag; + var nativeTopRootTag = nativeContainerInfo._tag; + UIManager.createView( + tag, + 'RCTRawText', + nativeTopRootTag, + {text: this._stringText} + ); + + ReactNativeComponentTree.precacheNode(this, tag); + + return tag; + }, + + getNativeNode: function() { + return this._rootNodeID; + }, + + receiveComponent: function(nextText, transaction, context) { + if (nextText !== this._currentElement) { + this._currentElement = nextText; + var nextStringText = '' + nextText; + if (nextStringText !== this._stringText) { + this._stringText = nextStringText; + UIManager.updateView( + this._rootNodeID, + 'RCTRawText', + {text: this._stringText} + ); + } + } + }, + + unmountComponent: function() { + ReactNativeComponentTree.uncacheNode(this); + this._currentElement = null; + this._stringText = null; + this._rootNodeID = null; + }, + +}); + +module.exports = ReactNativeTextComponent; diff --git a/src/renderers/native/ReactNative/ReactNativeTreeTraversal.js b/src/renderers/native/ReactNative/ReactNativeTreeTraversal.js new file mode 100644 index 0000000000..58a3a9e479 --- /dev/null +++ b/src/renderers/native/ReactNative/ReactNativeTreeTraversal.js @@ -0,0 +1,126 @@ +/** + * Copyright 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactNativeTreeTraversal + */ + +'use strict'; + +// Same as ReactDOMTreeTraversal without the invariants. + +/** + * Return the lowest common ancestor of A and B, or null if they are in + * different trees. + */ +function getLowestCommonAncestor(instA, instB) { + var depthA = 0; + for (var tempA = instA; tempA; tempA = tempA._nativeParent) { + depthA++; + } + var depthB = 0; + for (var tempB = instB; tempB; tempB = tempB._nativeParent) { + depthB++; + } + + // If A is deeper, crawl up. + while (depthA - depthB > 0) { + instA = instA._nativeParent; + depthA--; + } + + // If B is deeper, crawl up. + while (depthB - depthA > 0) { + instB = instB._nativeParent; + depthB--; + } + + // Walk in lockstep until we find a match. + var depth = depthA; + while (depth--) { + if (instA === instB) { + return instA; + } + instA = instA._nativeParent; + instB = instB._nativeParent; + } + return null; +} + +/** + * Return if A is an ancestor of B. + */ +function isAncestor(instA, instB) { + while (instB) { + if (instB === instA) { + return true; + } + instB = instB._nativeParent; + } + return false; +} + +/** + * Return the parent instance of the passed-in instance. + */ +function getParentInstance(inst) { + return inst._nativeParent; +} + +/** + * Simulates the traversal of a two-phase, capture/bubble event dispatch. + */ +function traverseTwoPhase(inst, fn, arg) { + var path = []; + while (inst) { + path.push(inst); + inst = inst._nativeParent; + } + var i; + for (i = path.length; i-- > 0;) { + fn(path[i], false, arg); + } + for (i = 0; i < path.length; i++) { + fn(path[i], true, arg); + } +} + +/** + * Traverses the ID hierarchy and invokes the supplied `cb` on any IDs that + * should would receive a `mouseEnter` or `mouseLeave` event. + * + * Does not invoke the callback on the nearest common ancestor because nothing + * "entered" or "left" that element. + */ +function traverseEnterLeave(from, to, fn, argFrom, argTo) { + var common = from && to ? getLowestCommonAncestor(from, to) : null; + var pathFrom = []; + while (from && from !== common) { + pathFrom.push(from); + from = from._nativeParent; + } + var pathTo = []; + while (to && to !== common) { + pathTo.push(to); + to = to._nativeParent; + } + var i; + for (i = 0; i < pathFrom.length; i++) { + fn(pathFrom[i], true, argFrom); + } + for (i = pathTo.length; i-- > 0;) { + fn(pathTo[i], false, argTo); + } +} + +module.exports = { + isAncestor: isAncestor, + getLowestCommonAncestor: getLowestCommonAncestor, + getParentInstance: getParentInstance, + traverseTwoPhase: traverseTwoPhase, + traverseEnterLeave: traverseEnterLeave, +}; diff --git a/src/renderers/native/ReactNative/__mocks__/InitializeJavaScriptAppEngine.js b/src/renderers/native/ReactNative/__mocks__/InitializeJavaScriptAppEngine.js new file mode 100644 index 0000000000..3f4b4fdd0f --- /dev/null +++ b/src/renderers/native/ReactNative/__mocks__/InitializeJavaScriptAppEngine.js @@ -0,0 +1,14 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +// Noop + +// TODO: Move all initialization callers back into react-native diff --git a/src/renderers/native/ReactNative/__mocks__/InteractionManager.js b/src/renderers/native/ReactNative/__mocks__/InteractionManager.js new file mode 100644 index 0000000000..de72e164a6 --- /dev/null +++ b/src/renderers/native/ReactNative/__mocks__/InteractionManager.js @@ -0,0 +1,16 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +// TODO: Figure out a way to drop this dependency + +var InteractionManager = {}; + +module.exports = InteractionManager; diff --git a/src/renderers/native/ReactNative/__mocks__/JSTimersExecution.js b/src/renderers/native/ReactNative/__mocks__/JSTimersExecution.js new file mode 100644 index 0000000000..3f4b4fdd0f --- /dev/null +++ b/src/renderers/native/ReactNative/__mocks__/JSTimersExecution.js @@ -0,0 +1,14 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +// Noop + +// TODO: Move all initialization callers back into react-native diff --git a/src/renderers/native/ReactNative/__mocks__/Platform.js b/src/renderers/native/ReactNative/__mocks__/Platform.js new file mode 100644 index 0000000000..fc04c24549 --- /dev/null +++ b/src/renderers/native/ReactNative/__mocks__/Platform.js @@ -0,0 +1,16 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +// Mock of the Native Hooks + +var Platform = {}; + +module.exports = Platform; diff --git a/src/renderers/native/ReactNative/__mocks__/RCTEventEmitter.js b/src/renderers/native/ReactNative/__mocks__/RCTEventEmitter.js new file mode 100644 index 0000000000..3f4b4fdd0f --- /dev/null +++ b/src/renderers/native/ReactNative/__mocks__/RCTEventEmitter.js @@ -0,0 +1,14 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +// Noop + +// TODO: Move all initialization callers back into react-native diff --git a/src/renderers/native/ReactNative/__mocks__/RCTLog.js b/src/renderers/native/ReactNative/__mocks__/RCTLog.js new file mode 100644 index 0000000000..3f4b4fdd0f --- /dev/null +++ b/src/renderers/native/ReactNative/__mocks__/RCTLog.js @@ -0,0 +1,14 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +// Noop + +// TODO: Move all initialization callers back into react-native diff --git a/src/renderers/native/ReactNative/__mocks__/TextInputState.js b/src/renderers/native/ReactNative/__mocks__/TextInputState.js new file mode 100644 index 0000000000..a0b4e576d7 --- /dev/null +++ b/src/renderers/native/ReactNative/__mocks__/TextInputState.js @@ -0,0 +1,17 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +// Mock of the Native Hooks +// TODO: Should this move into the components themselves? E.g. focusable + +var TextInputState = {}; + +module.exports = TextInputState; diff --git a/src/renderers/native/ReactNative/__mocks__/UIManager.js b/src/renderers/native/ReactNative/__mocks__/UIManager.js new file mode 100644 index 0000000000..0c51ab6e6b --- /dev/null +++ b/src/renderers/native/ReactNative/__mocks__/UIManager.js @@ -0,0 +1,21 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +// Mock of the Native Hooks + +var RCTUIManager = { + createView: jest.genMockFunction(), + setChildren: jest.genMockFunction(), + manageChildren: jest.genMockFunction(), + updateView: jest.genMockFunction(), +}; + +module.exports = RCTUIManager; diff --git a/src/renderers/native/ReactNative/__mocks__/deepDiffer.js b/src/renderers/native/ReactNative/__mocks__/deepDiffer.js new file mode 100644 index 0000000000..cc58e4b328 --- /dev/null +++ b/src/renderers/native/ReactNative/__mocks__/deepDiffer.js @@ -0,0 +1,63 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +// TODO: Move deepDiffer into react + +var deepDiffer = function(one: any, two: any): bool { + if (one === two) { + // Short circuit on identical object references instead of traversing them. + return false; + } + if ((typeof one === 'function') && (typeof two === 'function')) { + // We consider all functions equal + return false; + } + if ((typeof one !== 'object') || (one === null)) { + // Primitives can be directly compared + return one !== two; + } + if ((typeof two !== 'object') || (two === null)) { + // We know they are different because the previous case would have triggered + // otherwise. + return true; + } + if (one.constructor !== two.constructor) { + return true; + } + if (Array.isArray(one)) { + // We know two is also an array because the constructors are equal + var len = one.length; + if (two.length !== len) { + return true; + } + for (var ii = 0; ii < len; ii++) { + if (deepDiffer(one[ii], two[ii])) { + return true; + } + } + } else { + for (var key in one) { + if (deepDiffer(one[key], two[key])) { + return true; + } + } + for (var twoKey in two) { + // The only case we haven't checked yet is keys that are in two but aren't + // in one, which means they are different. + if (one[twoKey] === undefined && two[twoKey] !== undefined) { + return true; + } + } + } + return false; +}; + +module.exports = deepDiffer; diff --git a/src/renderers/native/ReactNative/__mocks__/deepFreezeAndThrowOnMutationInDev.js b/src/renderers/native/ReactNative/__mocks__/deepFreezeAndThrowOnMutationInDev.js new file mode 100644 index 0000000000..ebb81c3335 --- /dev/null +++ b/src/renderers/native/ReactNative/__mocks__/deepFreezeAndThrowOnMutationInDev.js @@ -0,0 +1,16 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +// TODO: move into react or fbjs + +var deepFreezeAndThrowOnMutationInDev = function() { }; + +module.exports = deepFreezeAndThrowOnMutationInDev; diff --git a/src/renderers/native/ReactNative/__mocks__/flattenStyle.js b/src/renderers/native/ReactNative/__mocks__/flattenStyle.js new file mode 100644 index 0000000000..a766c705fb --- /dev/null +++ b/src/renderers/native/ReactNative/__mocks__/flattenStyle.js @@ -0,0 +1,16 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +// TODO: Move flattenStyle into react + +var flattenStyle = function() { }; + +module.exports = flattenStyle; diff --git a/src/renderers/native/ReactNative/__mocks__/merge.js b/src/renderers/native/ReactNative/__mocks__/merge.js new file mode 100644 index 0000000000..0c2dc8bf5d --- /dev/null +++ b/src/renderers/native/ReactNative/__mocks__/merge.js @@ -0,0 +1,18 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +// TODO: Replace all callers with spread + +var merge = function(a, b) { + return {...a, ...b}; +}; + +module.exports = merge; diff --git a/src/renderers/native/ReactNative/__tests__/ReactNativeAttributePayload-test.js b/src/renderers/native/ReactNative/__tests__/ReactNativeAttributePayload-test.js new file mode 100644 index 0000000000..be65d175a2 --- /dev/null +++ b/src/renderers/native/ReactNative/__tests__/ReactNativeAttributePayload-test.js @@ -0,0 +1,246 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ +'use strict'; + +jest.dontMock('ReactNativeAttributePayload'); +jest.dontMock('ReactNativePropRegistry'); +// jest.dontMock('deepDiffer'); +// jest.dontMock('flattenStyle'); + +var ReactNativeAttributePayload = require('ReactNativeAttributePayload'); +var ReactNativePropRegistry = require('ReactNativePropRegistry'); + +var diff = ReactNativeAttributePayload.diff; + +describe('ReactNativeAttributePayload', function() { + + it('should work with simple example', () => { + expect(diff( + {a: 1, c: 3}, + {b: 2, c: 3}, + {a: true, b: true} + )).toEqual({a: null, b: 2}); + }); + + it('should skip fields that are equal', () => { + expect(diff( + {a: 1, b: 'two', c: true, d: false, e: undefined, f: 0}, + {a: 1, b: 'two', c: true, d: false, e: undefined, f: 0}, + {a: true, b: true, c: true, d: true, e: true, f: true} + )).toEqual(null); + }); + + it('should remove fields', () => { + expect(diff( + {a: 1}, + {}, + {a: true} + )).toEqual({a: null}); + }); + + it('should remove fields that are set to undefined', () => { + expect(diff( + {a: 1}, + {a: undefined}, + {a: true} + )).toEqual({a: null}); + }); + + it('should ignore invalid fields', () => { + expect(diff( + {a: 1}, + {b: 2}, + {} + )).toEqual(null); + }); + + it('should use the diff attribute', () => { + var diffA = jest.genMockFunction().mockImpl((a, b) => true); + var diffB = jest.genMockFunction().mockImpl((a, b) => false); + expect(diff( + {a: [1], b: [3]}, + {a: [2], b: [4]}, + {a: {diff: diffA}, b: {diff: diffB}} + )).toEqual({a: [2]}); + expect(diffA).toBeCalledWith([1], [2]); + expect(diffB).toBeCalledWith([3], [4]); + }); + + it('should not use the diff attribute on addition/removal', () => { + var diffA = jest.genMockFunction(); + var diffB = jest.genMockFunction(); + expect(diff( + {a: [1]}, + {b: [2]}, + {a: {diff: diffA}, b: {diff: diffB}} + )).toEqual({a: null, b: [2]}); + expect(diffA).not.toBeCalled(); + expect(diffB).not.toBeCalled(); + }); + + it('should do deep diffs of Objects by default', () => { + expect(diff( + {a: [1], b: {k: [3, 4]}, c: {k: [4, 4]} }, + {a: [2], b: {k: [3, 4]}, c: {k: [4, 5]} }, + {a: true, b: true, c: true} + )).toEqual({a: [2], c: {k: [4, 5]}}); + }); + + it('should work with undefined styles', () => { + expect(diff( + { style: { a: '#ffffff', b: 1 } }, + { style: undefined }, + { style: { b: true } } + )).toEqual({ b: null }); + expect(diff( + { style: undefined }, + { style: { a: '#ffffff', b: 1 } }, + { style: { b: true } } + )).toEqual({ b: 1 }); + expect(diff( + { style: undefined }, + { style: undefined }, + { style: { b: true } } + )).toEqual(null); + }); + + it('should work with empty styles', () => { + expect(diff( + {a: 1, c: 3}, + {}, + {a: true, b: true} + )).toEqual({a: null}); + expect(diff( + {}, + {a: 1, c: 3}, + {a: true, b: true} + )).toEqual({a: 1}); + expect(diff( + {}, + {}, + {a: true, b: true} + )).toEqual(null); + }); + + it('should flatten nested styles and predefined styles', () => { + var validStyleAttribute = { someStyle: { foo: true, bar: true } }; + + expect(diff( + {}, + { someStyle: [{ foo: 1 }, { bar: 2 }]}, + validStyleAttribute + )).toEqual({ foo: 1, bar: 2 }); + + expect(diff( + { someStyle: [{ foo: 1 }, { bar: 2 }]}, + {}, + validStyleAttribute + )).toEqual({ foo: null, bar: null }); + + var barStyle = ReactNativePropRegistry.register({ + bar: 3, + }); + + expect(diff( + {}, + { someStyle: [[{ foo: 1 }, { foo: 2 }], barStyle]}, + validStyleAttribute + )).toEqual({ foo: 2, bar: 3 }); + }); + + it('should reset a value to a previous if it is removed', () => { + var validStyleAttribute = { someStyle: { foo: true, bar: true } }; + + expect(diff( + { someStyle: [{ foo: 1 }, { foo: 3 }]}, + { someStyle: [{ foo: 1 }, { bar: 2 }]}, + validStyleAttribute + )).toEqual({ foo: 1, bar: 2 }); + }); + + it('should not clear removed props if they are still in another slot', () => { + var validStyleAttribute = { someStyle: { foo: true, bar: true } }; + + expect(diff( + { someStyle: [{}, { foo: 3, bar: 2 }]}, + { someStyle: [{ foo: 3 }, { bar: 2 }]}, + validStyleAttribute + )).toEqual({ foo: 3 }); // this should ideally be null. heuristic tradeoff. + + expect(diff( + { someStyle: [{}, { foo: 3, bar: 2 }]}, + { someStyle: [{ foo: 1, bar: 1 }, { bar: 2 }]}, + validStyleAttribute + )).toEqual({ bar: 2, foo: 1 }); + }); + + it('should clear a prop if a later style is explicit null/undefined', () => { + var validStyleAttribute = { someStyle: { foo: true, bar: true } }; + expect(diff( + { someStyle: [{}, { foo: 3, bar: 2 }]}, + { someStyle: [{ foo: 1 }, { bar: 2, foo: null }]}, + validStyleAttribute + )).toEqual({ foo: null }); + + expect(diff( + { someStyle: [{ foo: 3 }, { foo: null, bar: 2 }]}, + { someStyle: [{ foo: null }, { bar: 2 }]}, + validStyleAttribute + )).toEqual({ foo: null }); + + expect(diff( + { someStyle: [{ foo: 1 }, { foo: null }]}, + { someStyle: [{ foo: 2 }, { foo: null }]}, + validStyleAttribute + )).toEqual({ foo: null }); // this should ideally be null. heuristic. + + // Test the same case with object equality because an early bailout doesn't + // work in this case. + var fooObj = { foo: 3 }; + expect(diff( + { someStyle: [{ foo: 1 }, fooObj]}, + { someStyle: [{ foo: 2 }, fooObj]}, + validStyleAttribute + )).toEqual({ foo: 3 }); // this should ideally be null. heuristic. + + expect(diff( + { someStyle: [{ foo: 1 }, { foo: 3 }]}, + { someStyle: [{ foo: 2 }, { foo: undefined }]}, + validStyleAttribute + )).toEqual({ foo: null }); // this should ideally be null. heuristic. + }); + + // Function properties are just markers to native that events should be sent. + it('should convert functions to booleans', () => { + // Note that if the property changes from one function to another, we don't + // need to send an update. + expect(diff( + { + a: function() { + return 1; + }, + b: function() { + return 2; + }, + c: 3, + }, + { + b: function() { + return 9; + }, + c: function() { + return 3; + }, + }, + {a: true, b: true, c: true} + )).toEqual({a: null, c: true}); + }); + +}); diff --git a/src/renderers/native/ReactNative/__tests__/ReactNativeMount-test.js b/src/renderers/native/ReactNative/__tests__/ReactNativeMount-test.js new file mode 100644 index 0000000000..74f4f7f57f --- /dev/null +++ b/src/renderers/native/ReactNative/__tests__/ReactNativeMount-test.js @@ -0,0 +1,61 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails react-core + */ + +'use strict'; + +var React; +var ReactNative; +var createReactNativeComponentClass; +var UIManager; + +describe('ReactNative', function() { + beforeEach(function() { + React = require('React'); + ReactNative = require('ReactNative'); + UIManager = require('UIManager'); + createReactNativeComponentClass = require('createReactNativeComponentClass'); + }); + + it('should be able to create and render a native component', function() { + var View = createReactNativeComponentClass({ + validAttributes: { foo: true }, + uiViewClassName: 'View', + }); + + ReactNative.render(, 1); + expect(UIManager.createView).toBeCalled(); + expect(UIManager.setChildren).toBeCalled(); + expect(UIManager.manageChildren).not.toBeCalled(); + expect(UIManager.updateView).not.toBeCalled(); + }); + + it('should be able to create and update a native component', function() { + var View = createReactNativeComponentClass({ + validAttributes: { foo: true }, + uiViewClassName: 'View', + }); + + ReactNative.render(, 11); + + expect(UIManager.createView.mock.calls.length).toBe(2); + expect(UIManager.setChildren.mock.calls.length).toBe(2); + expect(UIManager.manageChildren).not.toBeCalled(); + expect(UIManager.updateView).not.toBeCalled(); + + ReactNative.render(, 11); + + expect(UIManager.createView.mock.calls.length).toBe(2); + expect(UIManager.setChildren.mock.calls.length).toBe(2); + expect(UIManager.manageChildren).not.toBeCalled(); + expect(UIManager.updateView).toBeCalledWith(3, 'View', { foo: 'bar' }); + }); + +}); diff --git a/src/renderers/native/ReactNative/createReactNativeComponentClass.js b/src/renderers/native/ReactNative/createReactNativeComponentClass.js new file mode 100644 index 0000000000..82d071b087 --- /dev/null +++ b/src/renderers/native/ReactNative/createReactNativeComponentClass.js @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule createReactNativeComponentClass + * @flow + */ + +'use strict'; + +var ReactNativeBaseComponent = require('ReactNativeBaseComponent'); + +// See also ReactNativeBaseComponent +type ReactNativeBaseComponentViewConfig = { + validAttributes: Object; + uiViewClassName: string; + propTypes?: Object, +} + +/** + * @param {string} config iOS View configuration. + * @private + */ +var createReactNativeComponentClass = function( + viewConfig: ReactNativeBaseComponentViewConfig +): ReactClass { + var Constructor = function(element) { + this._currentElement = element; + this._topLevelWrapper = null; + this._nativeParent = null; + this._nativeContainerInfo = null; + this._rootNodeID = null; + this._renderedChildren = null; + }; + Constructor.displayName = viewConfig.uiViewClassName; + Constructor.viewConfig = viewConfig; + Constructor.propTypes = viewConfig.propTypes; + Constructor.prototype = new ReactNativeBaseComponent(viewConfig); + Constructor.prototype.constructor = Constructor; + + return ((Constructor: any): ReactClass); +}; + +module.exports = createReactNativeComponentClass; diff --git a/src/renderers/native/ReactNative/findNodeHandle.js b/src/renderers/native/ReactNative/findNodeHandle.js new file mode 100644 index 0000000000..2979490de7 --- /dev/null +++ b/src/renderers/native/ReactNative/findNodeHandle.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule findNodeHandle + * @flow + */ + +'use strict'; + +var ReactCurrentOwner = require('ReactCurrentOwner'); +var ReactInstanceMap = require('ReactInstanceMap'); + +var invariant = require('invariant'); +var warning = require('warning'); + +/** + * ReactNative vs ReactWeb + * ----------------------- + * React treats some pieces of data opaquely. This means that the information + * is first class (it can be passed around), but cannot be inspected. This + * allows us to build infrastructure that reasons about resources, without + * making assumptions about the nature of those resources, and this allows that + * infra to be shared across multiple platforms, where the resources are very + * different. General infra (such as `ReactMultiChild`) reasons opaquely about + * the data, but platform specific code (such as `ReactNativeBaseComponent`) can + * make assumptions about the data. + * + * + * `rootNodeID`, uniquely identifies a position in the generated native view + * tree. Many layers of composite components (created with `React.createClass`) + * can all share the same `rootNodeID`. + * + * `nodeHandle`: A sufficiently unambiguous way to refer to a lower level + * resource (dom node, native view etc). The `rootNodeID` is sufficient for web + * `nodeHandle`s, because the position in a tree is always enough to uniquely + * identify a DOM node (we never have nodes in some bank outside of the + * document). The same would be true for `ReactNative`, but we must maintain a + * mapping that we can send efficiently serializable + * strings across native boundaries. + * + * Opaque name TodaysWebReact FutureWebWorkerReact ReactNative + * ---------------------------------------------------------------------------- + * nodeHandle N/A rootNodeID tag + */ + +function findNodeHandle(componentOrHandle: any): ?number { + if (__DEV__) { + var owner = ReactCurrentOwner.current; + if (owner !== null) { + warning( + owner._warnedAboutRefsInRender, + '%s is accessing findNodeHandle inside its render(). ' + + 'render() should be a pure function of props and state. It should ' + + 'never access something that requires stale data from the previous ' + + 'render, such as refs. Move this logic to componentDidMount and ' + + 'componentDidUpdate instead.', + owner.getName() || 'A component' + ); + owner._warnedAboutRefsInRender = true; + } + } + if (componentOrHandle == null) { + return null; + } + if (typeof componentOrHandle === 'number') { + // Already a node handle + return componentOrHandle; + } + + var component = componentOrHandle; + + // TODO (balpert): Wrap iOS native components in a composite wrapper, then + // ReactInstanceMap.get here will always succeed for mounted components + var internalInstance = ReactInstanceMap.get(component); + if (internalInstance) { + return internalInstance.getNativeNode(); + } else { + var rootNodeID = component._rootNodeID; + if (rootNodeID) { + return rootNodeID; + } else { + invariant( + ( + // Native + typeof component === 'object' && + '_rootNodeID' in component + ) || ( + // Composite + component.render != null && + typeof component.render === 'function' + ), + 'findNodeHandle(...): Argument is not a component ' + + '(type: %s, keys: %s)', + typeof component, + Object.keys(component) + ); + invariant( + false, + 'findNodeHandle(...): Unable to find node handle for unmounted ' + + 'component.' + ); + } + } +} + +module.exports = findNodeHandle; diff --git a/src/renderers/shared/event/EventPluginUtils.js b/src/renderers/shared/event/EventPluginUtils.js index f7e8efe12f..33019d27d6 100644 --- a/src/renderers/shared/event/EventPluginUtils.js +++ b/src/renderers/shared/event/EventPluginUtils.js @@ -204,7 +204,7 @@ function executeDirectDispatch(event) { !Array.isArray(dispatchListener), 'executeDirectDispatch(...): Invalid `event`.' ); - event.currentTarget = EventPluginUtils.getNodeFromInstance(dispatchInstance); + event.currentTarget = dispatchListener ? EventPluginUtils.getNodeFromInstance(dispatchInstance) : null; var res = dispatchListener ? dispatchListener(event) : null; event.currentTarget = null; event._dispatchListeners = null; diff --git a/src/renderers/shared/event/eventPlugins/PanResponder.js b/src/renderers/shared/event/eventPlugins/PanResponder.js new file mode 100644 index 0000000000..a102747fe6 --- /dev/null +++ b/src/renderers/shared/event/eventPlugins/PanResponder.js @@ -0,0 +1,379 @@ +/** + * @providesModule PanResponder + */ + +'use strict'; + +var TouchHistoryMath = require('TouchHistoryMath'); + +var currentCentroidXOfTouchesChangedAfter = + TouchHistoryMath.currentCentroidXOfTouchesChangedAfter; +var currentCentroidYOfTouchesChangedAfter = + TouchHistoryMath.currentCentroidYOfTouchesChangedAfter; +var previousCentroidXOfTouchesChangedAfter = + TouchHistoryMath.previousCentroidXOfTouchesChangedAfter; +var previousCentroidYOfTouchesChangedAfter = + TouchHistoryMath.previousCentroidYOfTouchesChangedAfter; +var currentCentroidX = TouchHistoryMath.currentCentroidX; +var currentCentroidY = TouchHistoryMath.currentCentroidY; + +/** + * `PanResponder` reconciles several touches into a single gesture. It makes + * single-touch gestures resilient to extra touches, and can be used to + * recognize simple multi-touch gestures. + * + * It provides a predictable wrapper of the responder handlers provided by the + * [gesture responder system](docs/gesture-responder-system.html). + * For each handler, it provides a new `gestureState` object alongside the + * native event object: + * + * ``` + * onPanResponderMove: (event, gestureState) => {} + * ``` + * + * A native event is a synthetic touch event with the following form: + * + * - `nativeEvent` + * + `changedTouches` - Array of all touch events that have changed since the last event + * + `identifier` - The ID of the touch + * + `locationX` - The X position of the touch, relative to the element + * + `locationY` - The Y position of the touch, relative to the element + * + `pageX` - The X position of the touch, relative to the root element + * + `pageY` - The Y position of the touch, relative to the root element + * + `target` - The node id of the element receiving the touch event + * + `timestamp` - A time identifier for the touch, useful for velocity calculation + * + `touches` - Array of all current touches on the screen + * + * A `gestureState` object has the following: + * + * - `stateID` - ID of the gestureState- persisted as long as there at least + * one touch on screen + * - `moveX` - the latest screen coordinates of the recently-moved touch + * - `moveY` - the latest screen coordinates of the recently-moved touch + * - `x0` - the screen coordinates of the responder grant + * - `y0` - the screen coordinates of the responder grant + * - `dx` - accumulated distance of the gesture since the touch started + * - `dy` - accumulated distance of the gesture since the touch started + * - `vx` - current velocity of the gesture + * - `vy` - current velocity of the gesture + * - `numberActiveTouches` - Number of touches currently on screen + * + * ### Basic Usage + * + * ``` + * componentWillMount: function() { + * this._panResponder = PanResponder.create({ + * // Ask to be the responder: + * onStartShouldSetPanResponder: (evt, gestureState) => true, + * onStartShouldSetPanResponderCapture: (evt, gestureState) => true, + * onMoveShouldSetPanResponder: (evt, gestureState) => true, + * onMoveShouldSetPanResponderCapture: (evt, gestureState) => true, + * + * onPanResponderGrant: (evt, gestureState) => { + * // The guesture has started. Show visual feedback so the user knows + * // what is happening! + * + * // gestureState.{x,y}0 will be set to zero now + * }, + * onPanResponderMove: (evt, gestureState) => { + * // The most recent move distance is gestureState.move{X,Y} + * + * // The accumulated gesture distance since becoming responder is + * // gestureState.d{x,y} + * }, + * onPanResponderTerminationRequest: (evt, gestureState) => true, + * onPanResponderRelease: (evt, gestureState) => { + * // The user has released all touches while this view is the + * // responder. This typically means a gesture has succeeded + * }, + * onPanResponderTerminate: (evt, gestureState) => { + * // Another component has become the responder, so this gesture + * // should be cancelled + * }, + * onShouldBlockNativeResponder: (evt, gestureState) => { + * // Returns whether this component should block native components from becoming the JS + * // responder. Returns true by default. Is currently only supported on android. + * return true; + * }, + * }); + * }, + * + * render: function() { + * return ( + * + * ); + * }, + * + * ``` + * + * ### Working Example + * + * To see it in action, try the + * [PanResponder example in UIExplorer](https://github.com/facebook/react-native/blob/master/Examples/UIExplorer/PanResponderExample.js) + */ + +var PanResponder = { + + /** + * + * A graphical explanation of the touch data flow: + * + * +----------------------------+ +--------------------------------+ + * | ResponderTouchHistoryStore | |TouchHistoryMath | + * +----------------------------+ +----------+---------------------+ + * |Global store of touchHistory| |Allocation-less math util | + * |including activeness, start | |on touch history (centroids | + * |position, prev/cur position.| |and multitouch movement etc) | + * | | | | + * +----^-----------------------+ +----^---------------------------+ + * | | + * | (records relevant history | + * | of touches relevant for | + * | implementing higher level | + * | gestures) | + * | | + * +----+-----------------------+ +----|---------------------------+ + * | ResponderEventPlugin | | | Your App/Component | + * +----------------------------+ +----|---------------------------+ + * |Negotiates which view gets | Low level | | High level | + * |onResponderMove events. | events w/ | +-+-------+ events w/ | + * |Also records history into | touchHistory| | Pan | multitouch + | + * |ResponderTouchHistoryStore. +---------------->Responder+-----> accumulative| + * +----------------------------+ attached to | | | distance and | + * each event | +---------+ velocity. | + * | | + * | | + * +--------------------------------+ + * + * + * + * Gesture that calculates cumulative movement over time in a way that just + * "does the right thing" for multiple touches. The "right thing" is very + * nuanced. When moving two touches in opposite directions, the cumulative + * distance is zero in each dimension. When two touches move in parallel five + * pixels in the same direction, the cumulative distance is five, not ten. If + * two touches start, one moves five in a direction, then stops and the other + * touch moves fives in the same direction, the cumulative distance is ten. + * + * This logic requires a kind of processing of time "clusters" of touch events + * so that two touch moves that essentially occur in parallel but move every + * other frame respectively, are considered part of the same movement. + * + * Explanation of some of the non-obvious fields: + * + * - moveX/moveY: If no move event has been observed, then `(moveX, moveY)` is + * invalid. If a move event has been observed, `(moveX, moveY)` is the + * centroid of the most recently moved "cluster" of active touches. + * (Currently all move have the same timeStamp, but later we should add some + * threshold for what is considered to be "moving"). If a palm is + * accidentally counted as a touch, but a finger is moving greatly, the palm + * will move slightly, but we only want to count the single moving touch. + * - x0/y0: Centroid location (non-cumulative) at the time of becoming + * responder. + * - dx/dy: Cumulative touch distance - not the same thing as sum of each touch + * distance. Accounts for touch moves that are clustered together in time, + * moving the same direction. Only valid when currently responder (otherwise, + * it only represents the drag distance below the threshold). + * - vx/vy: Velocity. + */ + + _initializeGestureState: function(gestureState) { + gestureState.moveX = 0; + gestureState.moveY = 0; + gestureState.x0 = 0; + gestureState.y0 = 0; + gestureState.dx = 0; + gestureState.dy = 0; + gestureState.vx = 0; + gestureState.vy = 0; + gestureState.numberActiveTouches = 0; + // All `gestureState` accounts for timeStamps up until: + gestureState._accountsForMovesUpTo = 0; + }, + + /** + * This is nuanced and is necessary. It is incorrect to continuously take all + * active *and* recently moved touches, find the centroid, and track how that + * result changes over time. Instead, we must take all recently moved + * touches, and calculate how the centroid has changed just for those + * recently moved touches, and append that change to an accumulator. This is + * to (at least) handle the case where the user is moving three fingers, and + * then one of the fingers stops but the other two continue. + * + * This is very different than taking all of the recently moved touches and + * storing their centroid as `dx/dy`. For correctness, we must *accumulate + * changes* in the centroid of recently moved touches. + * + * There is also some nuance with how we handle multiple moved touches in a + * single event. With the way `ReactNativeEventEmitter` dispatches touches as + * individual events, multiple touches generate two 'move' events, each of + * them triggering `onResponderMove`. But with the way `PanResponder` works, + * all of the gesture inference is performed on the first dispatch, since it + * looks at all of the touches (even the ones for which there hasn't been a + * native dispatch yet). Therefore, `PanResponder` does not call + * `onResponderMove` passed the first dispatch. This diverges from the + * typical responder callback pattern (without using `PanResponder`), but + * avoids more dispatches than necessary. + */ + _updateGestureStateOnMove: function(gestureState, touchHistory) { + gestureState.numberActiveTouches = touchHistory.numberActiveTouches; + gestureState.moveX = currentCentroidXOfTouchesChangedAfter( + touchHistory, + gestureState._accountsForMovesUpTo + ); + gestureState.moveY = currentCentroidYOfTouchesChangedAfter( + touchHistory, + gestureState._accountsForMovesUpTo + ); + var movedAfter = gestureState._accountsForMovesUpTo; + var prevX = previousCentroidXOfTouchesChangedAfter(touchHistory, movedAfter); + var x = currentCentroidXOfTouchesChangedAfter(touchHistory, movedAfter); + var prevY = previousCentroidYOfTouchesChangedAfter(touchHistory, movedAfter); + var y = currentCentroidYOfTouchesChangedAfter(touchHistory, movedAfter); + var nextDX = gestureState.dx + (x - prevX); + var nextDY = gestureState.dy + (y - prevY); + + // TODO: This must be filtered intelligently. + var dt = + (touchHistory.mostRecentTimeStamp - gestureState._accountsForMovesUpTo); + gestureState.vx = (nextDX - gestureState.dx) / dt; + gestureState.vy = (nextDY - gestureState.dy) / dt; + + gestureState.dx = nextDX; + gestureState.dy = nextDY; + gestureState._accountsForMovesUpTo = touchHistory.mostRecentTimeStamp; + }, + + /** + * @param {object} config Enhanced versions of all of the responder callbacks + * that provide not only the typical `ResponderSyntheticEvent`, but also the + * `PanResponder` gesture state. Simply replace the word `Responder` with + * `PanResponder` in each of the typical `onResponder*` callbacks. For + * example, the `config` object would look like: + * + * - `onMoveShouldSetPanResponder: (e, gestureState) => {...}` + * - `onMoveShouldSetPanResponderCapture: (e, gestureState) => {...}` + * - `onStartShouldSetPanResponder: (e, gestureState) => {...}` + * - `onStartShouldSetPanResponderCapture: (e, gestureState) => {...}` + * - `onPanResponderReject: (e, gestureState) => {...}` + * - `onPanResponderGrant: (e, gestureState) => {...}` + * - `onPanResponderStart: (e, gestureState) => {...}` + * - `onPanResponderEnd: (e, gestureState) => {...}` + * - `onPanResponderRelease: (e, gestureState) => {...}` + * - `onPanResponderMove: (e, gestureState) => {...}` + * - `onPanResponderTerminate: (e, gestureState) => {...}` + * - `onPanResponderTerminationRequest: (e, gestureState) => {...}` + * - `onShouldBlockNativeResponder: (e, gestureState) => {...}` + * + * In general, for events that have capture equivalents, we update the + * gestureState once in the capture phase and can use it in the bubble phase + * as well. + * + * Be careful with onStartShould* callbacks. They only reflect updated + * `gestureState` for start/end events that bubble/capture to the Node. + * Once the node is the responder, you can rely on every start/end event + * being processed by the gesture and `gestureState` being updated + * accordingly. (numberActiveTouches) may not be totally accurate unless you + * are the responder. + */ + create: function(config) { + var gestureState = { + // Useful for debugging + stateID: Math.random(), + }; + PanResponder._initializeGestureState(gestureState); + var panHandlers = { + onStartShouldSetResponder: function(e) { + return config.onStartShouldSetPanResponder === undefined ? false : + config.onStartShouldSetPanResponder(e, gestureState); + }, + onMoveShouldSetResponder: function(e) { + return config.onMoveShouldSetPanResponder === undefined ? false : + config.onMoveShouldSetPanResponder(e, gestureState); + }, + onStartShouldSetResponderCapture: function(e) { + // TODO: Actually, we should reinitialize the state any time + // touches.length increases from 0 active to > 0 active. + if (e.nativeEvent.touches.length === 1) { + PanResponder._initializeGestureState(gestureState); + } + gestureState.numberActiveTouches = e.touchHistory.numberActiveTouches; + return config.onStartShouldSetPanResponderCapture !== undefined ? + config.onStartShouldSetPanResponderCapture(e, gestureState) : false; + }, + + onMoveShouldSetResponderCapture: function(e) { + var touchHistory = e.touchHistory; + // Responder system incorrectly dispatches should* to current responder + // Filter out any touch moves past the first one - we would have + // already processed multi-touch geometry during the first event. + if (gestureState._accountsForMovesUpTo === touchHistory.mostRecentTimeStamp) { + return false; + } + PanResponder._updateGestureStateOnMove(gestureState, touchHistory); + return config.onMoveShouldSetPanResponderCapture ? + config.onMoveShouldSetPanResponderCapture(e, gestureState) : false; + }, + + onResponderGrant: function(e) { + gestureState.x0 = currentCentroidX(e.touchHistory); + gestureState.y0 = currentCentroidY(e.touchHistory); + gestureState.dx = 0; + gestureState.dy = 0; + if (config.onPanResponderGrant) config.onPanResponderGrant(e, gestureState); + // TODO: t7467124 investigate if this can be removed + return config.onShouldBlockNativeResponder === undefined ? true : + config.onShouldBlockNativeResponder(); + }, + + onResponderReject: function(e) { + if (config.onPanResponderReject) config.onPanResponderReject(e, gestureState); + }, + + onResponderRelease: function(e) { + if (config.onPanResponderRelease) config.onPanResponderRelease(e, gestureState); + PanResponder._initializeGestureState(gestureState); + }, + + onResponderStart: function(e) { + var touchHistory = e.touchHistory; + gestureState.numberActiveTouches = touchHistory.numberActiveTouches; + if (config.onPanResponderStart) config.onPanResponderStart(e, gestureState); + }, + + onResponderMove: function(e) { + var touchHistory = e.touchHistory; + // Guard against the dispatch of two touch moves when there are two + // simultaneously changed touches. + if (gestureState._accountsForMovesUpTo === touchHistory.mostRecentTimeStamp) { + return; + } + // Filter out any touch moves past the first one - we would have + // already processed multi-touch geometry during the first event. + PanResponder._updateGestureStateOnMove(gestureState, touchHistory); + if (config.onPanResponderMove) config.onPanResponderMove(e, gestureState); + }, + + onResponderEnd: function(e) { + var touchHistory = e.touchHistory; + gestureState.numberActiveTouches = touchHistory.numberActiveTouches; + if (config.onPanResponderEnd) config.onPanResponderEnd(e, gestureState); + }, + + onResponderTerminate: function(e) { + if (config.onPanResponderTerminate) { + config.onPanResponderTerminate(e, gestureState); + } + PanResponder._initializeGestureState(gestureState); + }, + + onResponderTerminationRequest: function(e) { + return config.onPanResponderTerminationRequest === undefined ? true : + config.onPanResponderTerminationRequest(e, gestureState); + }, + }; + return {panHandlers: panHandlers}; + }, +}; + +module.exports = PanResponder; diff --git a/src/renderers/shared/event/eventPlugins/TouchHistoryMath.js b/src/renderers/shared/event/eventPlugins/TouchHistoryMath.js new file mode 100644 index 0000000000..a2a725bd2f --- /dev/null +++ b/src/renderers/shared/event/eventPlugins/TouchHistoryMath.js @@ -0,0 +1,122 @@ +/** + * @providesModule TouchHistoryMath + */ + +'use strict'; + +var TouchHistoryMath = { + /** + * This code is optimized and not intended to look beautiful. This allows + * computing of touch centroids that have moved after `touchesChangedAfter` + * timeStamp. You can compute the current centroid involving all touches + * moves after `touchesChangedAfter`, or you can compute the previous + * centroid of all touches that were moved after `touchesChangedAfter`. + * + * @param {TouchHistoryMath} touchHistory Standard Responder touch track + * data. + * @param {number} touchesChangedAfter timeStamp after which moved touches + * are considered "actively moving" - not just "active". + * @param {boolean} isXAxis Consider `x` dimension vs. `y` dimension. + * @param {boolean} ofCurrent Compute current centroid for actively moving + * touches vs. previous centroid of now actively moving touches. + * @return {number} value of centroid in specified dimension. + */ + centroidDimension: function(touchHistory, touchesChangedAfter, isXAxis, ofCurrent) { + var touchBank = touchHistory.touchBank; + var total = 0; + var count = 0; + + var oneTouchData = touchHistory.numberActiveTouches === 1 ? + touchHistory.touchBank[touchHistory.indexOfSingleActiveTouch] : null; + + if (oneTouchData !== null) { + if (oneTouchData.touchActive && oneTouchData.currentTimeStamp > touchesChangedAfter) { + total += ofCurrent && isXAxis ? oneTouchData.currentPageX : + ofCurrent && !isXAxis ? oneTouchData.currentPageY : + !ofCurrent && isXAxis ? oneTouchData.previousPageX : + oneTouchData.previousPageY; + count = 1; + } + } else { + for (var i = 0; i < touchBank.length; i++) { + var touchTrack = touchBank[i]; + if (touchTrack !== null && + touchTrack !== undefined && + touchTrack.touchActive && + touchTrack.currentTimeStamp >= touchesChangedAfter) { + var toAdd; // Yuck, program temporarily in invalid state. + if (ofCurrent && isXAxis) { + toAdd = touchTrack.currentPageX; + } else if (ofCurrent && !isXAxis) { + toAdd = touchTrack.currentPageY; + } else if (!ofCurrent && isXAxis) { + toAdd = touchTrack.previousPageX; + } else { + toAdd = touchTrack.previousPageY; + } + total += toAdd; + count++; + } + } + } + return count > 0 ? total / count : TouchHistoryMath.noCentroid; + }, + + currentCentroidXOfTouchesChangedAfter: function(touchHistory, touchesChangedAfter) { + return TouchHistoryMath.centroidDimension( + touchHistory, + touchesChangedAfter, + true, // isXAxis + true // ofCurrent + ); + }, + + currentCentroidYOfTouchesChangedAfter: function(touchHistory, touchesChangedAfter) { + return TouchHistoryMath.centroidDimension( + touchHistory, + touchesChangedAfter, + false, // isXAxis + true // ofCurrent + ); + }, + + previousCentroidXOfTouchesChangedAfter: function(touchHistory, touchesChangedAfter) { + return TouchHistoryMath.centroidDimension( + touchHistory, + touchesChangedAfter, + true, // isXAxis + false // ofCurrent + ); + }, + + previousCentroidYOfTouchesChangedAfter: function(touchHistory, touchesChangedAfter) { + return TouchHistoryMath.centroidDimension( + touchHistory, + touchesChangedAfter, + false, // isXAxis + false // ofCurrent + ); + }, + + currentCentroidX: function(touchHistory) { + return TouchHistoryMath.centroidDimension( + touchHistory, + 0, // touchesChangedAfter + true, // isXAxis + true // ofCurrent + ); + }, + + currentCentroidY: function(touchHistory) { + return TouchHistoryMath.centroidDimension( + touchHistory, + 0, // touchesChangedAfter + false, // isXAxis + true // ofCurrent + ); + }, + + noCentroid: -1, +}; + +module.exports = TouchHistoryMath;