From 52d8851fc8d7dc95d0ccff0d9775eece30b983b8 Mon Sep 17 00:00:00 2001 From: Peter Salanki Date: Tue, 17 Jan 2017 16:58:27 -0800 Subject: [PATCH] Cache policy control for image source Summary: In the context of an app an image exists in three resolutions on the server: `thumb` (30px) `feed` (300px) `full` (900px). When looking at an individual item a user can come either from the feed, via a permalink or from other parts of the app. This allows a situation where the `feed` image might or might not already be loaded somewhere in the app. In the detail view I want to render `thumb` with a blur (to quickly display something), then the `feed` image if it exists to have something decent to display until `full` loads. However it is quite a waste to load the `feed` image if it isn't already in cache, and will slow down the time until `full` is loaded. It is possible to track the navigation from feed->detail and that the `feed` image has actually completed loading by the feed component however as component hierarchies grow this turns into quite a lot of prop passing and bad separation of concerns. NSURLRequests accepts a [Cache Policy](https://developer.apple.com/reference/fo Closes https://github.com/facebook/react-native/pull/10844 Differential Revision: D4425959 Pulled By: lacker fbshipit-source-id: 679835439c761a2fc894f56eb6d744c036cf0b49 --- .../UIExplorerIntegrationTests.m | 1 + Examples/UIExplorer/js/ImageExample.js | 29 +++++ IntegrationTests/ImageCachePolicyTest.js | 115 ++++++++++++++++++ IntegrationTests/IntegrationTestsApp.js | 1 + Libraries/Image/Image.ios.js | 3 +- Libraries/Image/ImageSourcePropType.js | 26 ++++ React/Base/RCTConvert.h | 1 + React/Base/RCTConvert.m | 12 +- docs/Images.md | 20 +++ 9 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 IntegrationTests/ImageCachePolicyTest.js diff --git a/Examples/UIExplorer/UIExplorerIntegrationTests/UIExplorerIntegrationTests.m b/Examples/UIExplorer/UIExplorerIntegrationTests/UIExplorerIntegrationTests.m index d508c50163e155..e6ccdad9d5ce0f 100644 --- a/Examples/UIExplorer/UIExplorerIntegrationTests/UIExplorerIntegrationTests.m +++ b/Examples/UIExplorer/UIExplorerIntegrationTests/UIExplorerIntegrationTests.m @@ -67,6 +67,7 @@ - (void)testTheTester_ExpectError RCT_TEST(TimersTest) RCT_TEST(AsyncStorageTest) RCT_TEST(AppEventsTest) +RCT_TEST(ImageCachePolicyTest) //RCT_TEST(ImageSnapshotTest) // Disabled: #8985988 //RCT_TEST(LayoutEventsTest) // Disabled due to flakiness: #8686784 RCT_TEST(SimpleSnapshotTest) diff --git a/Examples/UIExplorer/js/ImageExample.js b/Examples/UIExplorer/js/ImageExample.js index 67c379cfe16e9a..d0951ed302c52b 100644 --- a/Examples/UIExplorer/js/ImageExample.js +++ b/Examples/UIExplorer/js/ImageExample.js @@ -304,6 +304,35 @@ exports.examples = [ }, platform: 'ios', }, + { + title: 'Cache Policy', + description: 'First image has never been loaded before and is instructed not to load unless in cache.' + + 'Placeholder image from above will stay. Second image is the same but forced to load regardless of' + + ' local cache state.', + render: function () { + return ( + + + + + ); + }, + platform: 'ios', + }, { title: 'Border Color', render: function() { diff --git a/IntegrationTests/ImageCachePolicyTest.js b/IntegrationTests/ImageCachePolicyTest.js new file mode 100644 index 00000000000000..f597dc26bd9bf7 --- /dev/null +++ b/IntegrationTests/ImageCachePolicyTest.js @@ -0,0 +1,115 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +var React = require('react'); +var ReactNative = require('react-native'); +var { + Image, + View, + Text, + StyleSheet, +} = ReactNative; +var { TestModule } = ReactNative.NativeModules; + +/* + * The reload and force-cache tests don't actually verify that the complete functionality. + * + * reload: Should have the server set a long cache header, then swap the image on next load + * with the test comparing the old image to the new image and making sure they are different. + * + * force-cache: Should do the above but set a no-cache header. The test should compare the first + * image with the new one and make sure they are the same. + */ + +const TESTS = ['only-if-cached', 'default', 'reload', 'force-cache']; + +type Props = {} +type State = { + 'only-if-cached'?: boolean, + 'default'?: boolean, + 'reload'?: boolean, + 'force-cache'?: boolean, +} + +class ImageCachePolicyTest extends React.Component { + state = {} + + shouldComponentUpdate(nextProps: Props, nextState: State) { + const results: Array = TESTS.map(x => nextState[x]); + + if (!results.includes(undefined)) { + const result: boolean = results.reduce((x,y) => x === y === true, true) + TestModule.markTestPassed(result); + } + + return false; + } + + testComplete(name: string, pass: boolean) { + this.setState({[name]: pass}); + } + + render() { + return ( + + Hello + this.testComplete('only-if-cached', false)} + onError={() => this.testComplete('only-if-cached', true)} + style={styles.base} + /> + this.testComplete('default', true)} + onError={() => this.testComplete('default', false)} + style={styles.base} + /> + this.testComplete('reload', true)} + onError={() => this.testComplete('reload', false)} + style={styles.base} + /> + this.testComplete('force-cache', true)} + onError={() => this.testComplete('force-cache', false)} + style={styles.base} + /> + + ); + } +} + +const styles = StyleSheet.create({ + base: { + width: 100, + height: 100, + }, +}); + +ImageCachePolicyTest.displayName = 'ImageCachePolicyTest'; + +module.exports = ImageCachePolicyTest; diff --git a/IntegrationTests/IntegrationTestsApp.js b/IntegrationTests/IntegrationTestsApp.js index 1a8a27caad4bce..efc5e697929706 100644 --- a/IntegrationTests/IntegrationTestsApp.js +++ b/IntegrationTests/IntegrationTestsApp.js @@ -29,6 +29,7 @@ var TESTS = [ require('./LayoutEventsTest'), require('./AppEventsTest'), require('./SimpleSnapshotTest'), + require('./ImageCachePolicyTest'), require('./ImageSnapshotTest'), require('./PromiseTest'), require('./WebSocketTest'), diff --git a/Libraries/Image/Image.ios.js b/Libraries/Image/Image.ios.js index 6da32488cb2cbc..70b804f37cab6b 100644 --- a/Libraries/Image/Image.ios.js +++ b/Libraries/Image/Image.ios.js @@ -140,7 +140,8 @@ const Image = React.createClass({ * This prop can also contain several remote URLs, specified together with * their width and height and potentially with scale/other URI arguments. * The native side will then choose the best `uri` to display based on the - * measured size of the image container. + * measured size of the image container. A `cache` property can be added to + * control how networked request interacts with the local cache. */ source: ImageSourcePropType, /** diff --git a/Libraries/Image/ImageSourcePropType.js b/Libraries/Image/ImageSourcePropType.js index 2b32c169c874d5..4c7b3a7c90f3ef 100644 --- a/Libraries/Image/ImageSourcePropType.js +++ b/Libraries/Image/ImageSourcePropType.js @@ -42,6 +42,32 @@ const ImageURISourcePropType = PropTypes.shape({ * additional encoding (e.g. URL-escaping or base64) applied. */ body: PropTypes.string, + /** + * `cache` determines how the requests handles potentially cached + * responses. + * + * - `default`: Use the native platforms default strategy. `useProtocolCachePolicy` on iOS. + * + * - `reload`: The data for the URL will be loaded from the originating source. + * No existing cache data should be used to satisfy a URL load request. + * + * - `force-cache`: The existing cached data will be used to satisfy the request, + * regardless of its age or expiration date. If there is no existing data in the cache + * corresponding the request, the data is loaded from the originating source. + * + * - `only-if-cached`: The existing cache data will be used to satisfy a request, regardless of + * its age or expiration date. If there is no existing data in the cache corresponding + * to a URL load request, no attempt is made to load the data from the originating source, + * and the load is considered to have failed. + * + * @platform ios + */ + cache: PropTypes.oneOf([ + 'default', + 'reload', + 'force-cache', + 'only-if-cached', + ]), /** * `width` and `height` can be specified if known at build time, in which case * these will be used to set the default `` component dimensions. diff --git a/React/Base/RCTConvert.h b/React/Base/RCTConvert.h index 117d990a6ebfcc..a71cc7857e3f10 100644 --- a/React/Base/RCTConvert.h +++ b/React/Base/RCTConvert.h @@ -47,6 +47,7 @@ + (NSData *)NSData:(id)json; + (NSIndexSet *)NSIndexSet:(id)json; ++ (NSURLRequestCachePolicy)NSURLRequestCachePolicy:(id)json; + (NSURL *)NSURL:(id)json; + (NSURLRequest *)NSURLRequest:(id)json; diff --git a/React/Base/RCTConvert.m b/React/Base/RCTConvert.m index c834d16644e944..084caec3494cde 100644 --- a/React/Base/RCTConvert.m +++ b/React/Base/RCTConvert.m @@ -118,6 +118,14 @@ + (NSURL *)NSURL:(id)json } } +RCT_ENUM_CONVERTER(NSURLRequestCachePolicy, (@{ + @"default": @(NSURLRequestUseProtocolCachePolicy), + @"reload": @(NSURLRequestReloadIgnoringLocalCacheData), + @"force-cache": @(NSURLRequestReturnCacheDataElseLoad), + @"only-if-cached": @(NSURLRequestReturnCacheDataDontLoad), + }), NSURLRequestUseProtocolCachePolicy, integerValue) + + + (NSURLRequest *)NSURLRequest:(id)json { if ([json isKindOfClass:[NSString class]]) { @@ -140,8 +148,9 @@ + (NSURLRequest *)NSURLRequest:(id)json NSData *body = [self NSData:json[@"body"]]; NSString *method = [self NSString:json[@"method"]].uppercaseString ?: @"GET"; + NSURLRequestCachePolicy cachePolicy = [self NSURLRequestCachePolicy:json[@"cache"]]; NSDictionary *headers = [self NSDictionary:json[@"headers"]]; - if ([method isEqualToString:@"GET"] && headers == nil && body == nil) { + if ([method isEqualToString:@"GET"] && headers == nil && body == nil && cachePolicy == NSURLRequestUseProtocolCachePolicy) { return [NSURLRequest requestWithURL:URL]; } @@ -164,6 +173,7 @@ + (NSURLRequest *)NSURLRequest:(id)json NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:URL]; request.HTTPBody = body; request.HTTPMethod = method; + request.cachePolicy = cachePolicy; request.allHTTPHeaderFields = headers; return [request copy]; } diff --git a/docs/Images.md b/docs/Images.md index 7bc89c3b2c10ee..0d43ca276e132b 100644 --- a/docs/Images.md +++ b/docs/Images.md @@ -89,6 +89,26 @@ Many of the images you will display in your app will not be available at compile ``` +### Cache Control (iOS Only) + +In some cases you might only want to display an image if it is already in the local cache, i.e. a low resolution placeholder until a higher resolution is available. In other cases you do not care if the image is outdated and are willing to display an outdated image to save bandwidth. The `cache` source property gives you control over how the network layer interacts with the cache. + +* `default`: Use the native platforms default strategy. +* `reload`: The data for the URL will be loaded from the originating source. +No existing cache data should be used to satisfy a URL load request. +* `force-cache`: The existing cached data will be used to satisfy the request, +regardless of its age or expiration date. If there is no existing data in the cache +corresponding the request, the data is loaded from the originating source. +* `only-if-cached`: The existing cache data will be used to satisfy a request, regardless of +its age or expiration date. If there is no existing data in the cache corresponding +to a URL load request, no attempt is made to load the data from the originating source, +and the load is considered to have failed. + +```javascript + +```` + ## Local Filesystem Images See [CameraRoll](docs/cameraroll.html) for an example of