diff --git a/example/package.json b/example/package.json
index cf2520d..cd7ac02 100644
--- a/example/package.json
+++ b/example/package.json
@@ -15,7 +15,8 @@
"react": "18.2.0",
"react-native": "0.73.6",
"react-native-safe-area-context": "^4.9.0",
- "react-native-screens": "^3.29.0"
+ "react-native-screens": "^3.29.0",
+ "react-native-server-component": "link:../src"
},
"devDependencies": {
"@babel/core": "^7.20.0",
diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx
index 7462327..54160c4 100644
--- a/example/src/screens/HomeScreen.tsx
+++ b/example/src/screens/HomeScreen.tsx
@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
-import { View, StyleSheet, Text, Pressable } from 'react-native';
+import { View, StyleSheet, Text } from 'react-native';
import { ServerComponent } from 'react-native-server-component';
export default function HomeScreen({ navigation }) {
@@ -14,10 +14,18 @@ export default function HomeScreen({ navigation }) {
[navigation]
);
+ const FallbackComponent = () => {
+ return (
+
+ Fallback Component
+
+ );
+ };
return (
}
onAction={handleAction}
/>
{/*
diff --git a/example/yarn.lock b/example/yarn.lock
index 101ca5d..240f7b6 100644
--- a/example/yarn.lock
+++ b/example/yarn.lock
@@ -5377,9 +5377,16 @@ __metadata:
react-native: 0.73.6
react-native-safe-area-context: ^4.9.0
react-native-screens: ^3.29.0
+ react-native-server-component: "link:../src"
languageName: unknown
linkType: soft
+"react-native-server-component@link:../src::locator=react-native-server-component-example%40workspace%3A.":
+ version: 0.0.0-use.local
+ resolution: "react-native-server-component@link:../src::locator=react-native-server-component-example%40workspace%3A."
+ languageName: node
+ linkType: soft
+
"react-native@npm:0.73.6":
version: 0.73.6
resolution: "react-native@npm:0.73.6"
diff --git a/server/Mocks/TranspiledExample.js b/server/Mocks/TranspiledExample.js
index d8db6d8..c45ad01 100644
--- a/server/Mocks/TranspiledExample.js
+++ b/server/Mocks/TranspiledExample.js
@@ -1 +1,154 @@
-"use strict";var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");var _typeof=require("@babel/runtime/helpers/typeof");Object.defineProperty(exports,"__esModule",{value:true});exports["default"]=void 0;var _slicedToArray2=_interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));var _react=_interopRequireWildcard(require("react"));var _reactNative=require("react-native");var _this=void 0,_jsxFileName="/Users/kunal.chavhan/workplace/react-native-server-component/server/Mocks/ExampleServerComponent.tsx";function _getRequireWildcardCache(e){if("function"!=typeof WeakMap)return null;var r=new WeakMap(),t=new WeakMap();return(_getRequireWildcardCache=function _getRequireWildcardCache(e){return e?t:r;})(e);}function _interopRequireWildcard(e,r){if(!r&&e&&e.__esModule)return e;if(null===e||"object"!=_typeof(e)&&"function"!=typeof e)return{"default":e};var t=_getRequireWildcardCache(r);if(t&&t.has(e))return t.get(e);var n={__proto__:null},a=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var u in e)if("default"!==u&&{}.hasOwnProperty.call(e,u)){var i=a?Object.getOwnPropertyDescriptor(e,u):null;i&&(i.get||i.set)?Object.defineProperty(n,u,i):n[u]=e[u];}return n["default"]=e,t&&t.set(e,n),n;}var ExampleServerComponent=function ExampleServerComponent(_ref){var onAction=_ref.onAction;var _useState=(0,_react.useState)(''),_useState2=(0,_slicedToArray2["default"])(_useState,2),catFact=_useState2[0],setCatFact=_useState2[1];var onPress=(0,_react.useCallback)(function(){if(onAction){onAction('NAVIGATE',{route:'DetailsScreen'});}},[onAction]);(0,_react.useEffect)(function(){fetch('https://catfact.ninja/fact').then(function(resp){return resp.json();}).then(function(json){return json.fact;}).then(function(fact){return setCatFact(fact);});},[]);return _react["default"].createElement(_reactNative.View,{style:styles.container,__self:_this,__source:{fileName:_jsxFileName,lineNumber:24,columnNumber:5}},_react["default"].createElement(_reactNative.Text,{style:styles.hello,__self:_this,__source:{fileName:_jsxFileName,lineNumber:25,columnNumber:7}}," Hello Server Component"),_react["default"].createElement(_reactNative.Text,{style:styles.catFactsTitle,__self:_this,__source:{fileName:_jsxFileName,lineNumber:26,columnNumber:7}}," Cat Facts "),_react["default"].createElement(_reactNative.Text,{style:styles.facts,__self:_this,__source:{fileName:_jsxFileName,lineNumber:27,columnNumber:7}}," ",catFact," "),_react["default"].createElement(_reactNative.Pressable,{onPress:onPress,__self:_this,__source:{fileName:_jsxFileName,lineNumber:28,columnNumber:7}},_react["default"].createElement(_reactNative.View,{style:styles.button,__self:_this,__source:{fileName:_jsxFileName,lineNumber:29,columnNumber:9}},_react["default"].createElement(_reactNative.Text,{style:styles.text,__self:_this,__source:{fileName:_jsxFileName,lineNumber:30,columnNumber:11}}," ","Navigation"," "))));};var styles=_reactNative.StyleSheet.create({container:{flex:1,width:'100%',justifyContent:'center',padding:20},hello:{color:'red',fontWeight:'bold'},catFactsTitle:{marginTop:16,color:'blue',fontWeight:'bold'},facts:{marginTop:10,color:'black',fontWeight:'400'},text:{color:'black',fontWeight:'400',alignContent:'center',textAlign:'center'},button:{height:30,width:100,marginTop:20,borderRadius:3,backgroundColor:'#65A765',justifyContent:'center',alignContent:'center',alignSelf:'center'}});var _default=exports["default"]=ExampleServerComponent;
+'use strict';
+var _interopRequireDefault = require('@babel/runtime/helpers/interopRequireDefault');
+var _typeof = require('@babel/runtime/helpers/typeof');
+Object.defineProperty(exports, '__esModule', { value: true });
+exports['default'] = void 0;
+var _slicedToArray2 = _interopRequireDefault(
+ require('@babel/runtime/helpers/slicedToArray')
+);
+var _react = _interopRequireWildcard(require('react'));
+var _reactNative = require('react-native');
+var _this = void 0,
+ _jsxFileName =
+ '/Users/kunal.chavhan/workplace/react-native-server-component/server/Mocks/ExampleServerComponent.tsx';
+function _getRequireWildcardCache(e) {
+ if ('function' != typeof WeakMap) return null;
+ var r = new WeakMap(),
+ t = new WeakMap();
+ return (_getRequireWildcardCache = function _getRequireWildcardCache(e) {
+ return e ? t : r;
+ })(e);
+}
+function _interopRequireWildcard(e, r) {
+ if (!r && e && e.__esModule) return e;
+ if (null === e || ('object' != _typeof(e) && 'function' != typeof e))
+ return { default: e };
+ var t = _getRequireWildcardCache(r);
+ if (t && t.has(e)) return t.get(e);
+ var n = { __proto__: null },
+ a = Object.defineProperty && Object.getOwnPropertyDescriptor;
+ for (var u in e)
+ if ('default' !== u && {}.hasOwnProperty.call(e, u)) {
+ var i = a ? Object.getOwnPropertyDescriptor(e, u) : null;
+ i && (i.get || i.set) ? Object.defineProperty(n, u, i) : (n[u] = e[u]);
+ }
+ return (n['default'] = e), t && t.set(e, n), n;
+}
+var ExampleServerComponent = function ExampleServerComponent(_ref) {
+ var onAction = _ref.onAction;
+ var _useState = (0, _react.useState)(''),
+ _useState2 = (0, _slicedToArray2['default'])(_useState, 2),
+ catFact = _useState2[0],
+ setCatFact = _useState2[1];
+ var onPress = (0, _react.useCallback)(
+ function () {
+ if (onAction) {
+ onAction('NAVIGATE', { route: 'DetailsScreen' });
+ }
+ },
+ [onAction]
+ );
+ (0, _react.useEffect)(function () {
+ fetch('https://catfact.ninja/fact')
+ .then(function (resp) {
+ return resp.json();
+ })
+ .then(function (json) {
+ return json.fact;
+ })
+ .then(function (fact) {
+ return setCatFact(fact);
+ });
+ }, []);
+ return _react['default'].createElement(
+ _reactNative.View,
+ {
+ style: styles.container,
+ __self: _this,
+ __source: { fileName: _jsxFileName, lineNumber: 24, columnNumber: 5 },
+ },
+ _react['default'].createElement(
+ _reactNative.Text,
+ {
+ style: styles.hello,
+ __self: _this,
+ __source: { fileName: _jsxFileName, lineNumber: 25, columnNumber: 7 },
+ },
+ ' Hello Server Component'
+ ),
+ _react['default'].createElement(
+ _reactNative.Text,
+ {
+ style: styles.catFactsTitle,
+ __self: _this,
+ __source: { fileName: _jsxFileName, lineNumber: 26, columnNumber: 7 },
+ },
+ ' Cat Facts '
+ ),
+ _react['default'].createElement(
+ _reactNative.Text,
+ {
+ style: styles.facts,
+ __self: _this,
+ __source: { fileName: _jsxFileName, lineNumber: 27, columnNumber: 7 },
+ },
+ ' ',
+ catFact,
+ ' '
+ ),
+ _react['default'].createElement(
+ _reactNative.Pressable,
+ {
+ onPress: onPress,
+ __self: _this,
+ __source: { fileName: _jsxFileName, lineNumber: 28, columnNumber: 7 },
+ },
+ _react['default'].createElement(
+ _reactNative.View,
+ {
+ style: styles.button,
+ __self: _this,
+ __source: { fileName: _jsxFileName, lineNumber: 29, columnNumber: 9 },
+ },
+ _react['default'].createElement(
+ _reactNative.Text,
+ {
+ style: styles.text,
+ __self: _this,
+ __source: {
+ fileName: _jsxFileName,
+ lineNumber: 30,
+ columnNumber: 11,
+ },
+ },
+ ' ',
+ 'Navigation',
+ ' '
+ )
+ )
+ )
+ );
+};
+var styles = _reactNative.StyleSheet.create({
+ container: { flex: 1, width: '100%', justifyContent: 'center', padding: 20 },
+ hello: { color: 'red', fontWeight: 'bold' },
+ catFactsTitle: { marginTop: 16, color: 'blue', fontWeight: 'bold' },
+ facts: { marginTop: 10, color: 'black', fontWeight: '400' },
+ text: {
+ color: 'black',
+ fontWeight: '400',
+ alignContent: 'center',
+ textAlign: 'center',
+ },
+ button: {
+ height: 30,
+ width: 100,
+ marginTop: 20,
+ borderRadius: 3,
+ backgroundColor: '#65A765',
+ justifyContent: 'center',
+ alignContent: 'center',
+ alignSelf: 'center',
+ },
+});
+var _default = (exports['default'] = ExampleServerComponent);
diff --git a/src/@types/index.ts b/src/@types/index.ts
index 993c9cb..c15caa0 100644
--- a/src/@types/index.ts
+++ b/src/@types/index.ts
@@ -13,9 +13,9 @@ export type RSCActions = 'NAVIGATE' | 'IO' | 'STATE_CHANGE';
export type RSCProps = {
readonly global?: any;
readonly source: RSCSource;
- readonly fallbackComponent?: () => JSX.Element;
- readonly loadingComponent?: () => JSX.Element;
- readonly errorComponent?: () => JSX.Element;
+ readonly fallbackComponent?: JSX.Element;
+ readonly loadingComponent?: JSX.Element;
+ readonly errorComponent?: JSX.Element;
readonly onError?: (error: Error) => void;
readonly navigationRef?: React.Ref;
readonly onAction?: (
diff --git a/src/cache/componentCache.ts b/src/cache/componentCache.ts
index d9231c2..eca50a7 100644
--- a/src/cache/componentCache.ts
+++ b/src/cache/componentCache.ts
@@ -1,10 +1,20 @@
+export interface CacheItem {
+ // React component
+ value: T;
+ // TTL in milliseconds
+ // Default is -1, which means cache will persist in app session
+ // Cache will be purged for any new requests, based on ttl and timestamp values
+ ttl: number;
+ // Timestamp at which cache was created
+ timestamp: number;
+}
+
class ComponentCache {
- // TODO implement TTL based cache bursting
private static instance: ComponentCache;
- private cache: Map;
+ private cache: Map | null>;
constructor() {
- this.cache = new Map();
+ this.cache = new Map>();
}
public static getInstance(): ComponentCache {
@@ -14,18 +24,34 @@ class ComponentCache {
return ComponentCache.instance;
}
- set(key: string, value: T | null): void {
+ set(key: string, value: CacheItem | null): void {
this.cache.set(key, value);
}
get(key: string): T | null {
const component = this.cache.get(key);
if (component) {
- return component;
+ return component.value;
}
return null;
}
+ getTTL(key: string): number {
+ const value = this.cache.get(key);
+ if (value && value.ttl) {
+ return value.ttl;
+ }
+ return -1;
+ }
+
+ getTimeStamp(key: string): number {
+ const value = this.cache.get(key);
+ if (value && value.timestamp) {
+ return value.timestamp;
+ }
+ return -1;
+ }
+
delete(key: string): void {
this.cache.delete(key);
}
diff --git a/src/component/ServerComponent.tsx b/src/component/ServerComponent.tsx
index 280aca5..e517df8 100644
--- a/src/component/ServerComponent.tsx
+++ b/src/component/ServerComponent.tsx
@@ -5,8 +5,8 @@ export default function RSC({
source,
openRSC,
fallbackComponent,
- loadingComponent = () => ,
- errorComponent = () => ,
+ loadingComponent = ,
+ errorComponent = ,
...extras
}: RSCProps): JSX.Element {
const [ServerComponent, setServerComponent] =
@@ -31,11 +31,25 @@ export default function RSC({
const FallbackComponent = React.useCallback((): JSX.Element => {
if (fallbackComponent) {
- return fallbackComponent();
+ return fallbackComponent;
}
return <>>;
}, [fallbackComponent]);
+ const ErrorComponent = React.useCallback((): JSX.Element => {
+ if (errorComponent) {
+ return errorComponent;
+ }
+ return <>>;
+ }, [errorComponent]);
+
+ const LoadingComponent = React.useCallback((): JSX.Element => {
+ if (loadingComponent) {
+ return loadingComponent;
+ }
+ return <>>;
+ }, [loadingComponent]);
+
if (typeof ServerComponent === 'function') {
return (
@@ -45,7 +59,7 @@ export default function RSC({
);
} else if (error) {
- return errorComponent();
+ return ;
}
- return loadingComponent();
+ return ;
}
diff --git a/src/component/createServerComponent.tsx b/src/component/createServerComponent.tsx
index b734d12..ae97520 100644
--- a/src/component/createServerComponent.tsx
+++ b/src/component/createServerComponent.tsx
@@ -4,6 +4,8 @@ import type { RSCPromise, RSCConfig, RSCSource, RSCProps } from '../@types';
import RSC from './ServerComponent';
import axios, { type AxiosRequestConfig } from 'axios';
import ComponentCache from '../cache/componentCache';
+import { RSCResponseHeaders } from '../utils/constants';
+import { getTTLFromResponseHeaders } from '../utils/utils';
const cache = ComponentCache.getInstance();
@@ -69,17 +71,28 @@ const buildRequest =
readonly component: (src: string) => Promise;
}) =>
async (uri: string, callback: RSCPromise) => {
- //const handler = completionHandler();
try {
const result = await axiosRequest({ url: uri, method: 'get' });
- const { data } = result;
+ const { data, headers } = result;
+ var ttl = getTTLFromResponseHeaders(headers);
+ if (
+ headers[RSCResponseHeaders.ttl] &&
+ headers[RSCResponseHeaders.ttl] !== ''
+ ) {
+ ttl = headers[RSCResponseHeaders.ttl];
+ }
+
if (typeof data !== 'string') {
throw new Error(
`[ServerComponent]: Expected string data, encountered ${typeof data}`
);
}
const Component = await component(data);
- cache.set(uri, Component);
+ cache.set(uri, {
+ value: Component,
+ ttl: ttl,
+ timestamp: Date.now(),
+ });
return callback.resolve(Component);
} catch (e) {
cache.set(uri, null);
@@ -98,13 +111,27 @@ const buildURIForRSC =
) => void;
}) =>
(uri: string, callback: RSCPromise): void => {
- const Component = cache.get(uri);
+ const cachedTimeStamp: number = cache.getTimeStamp(uri);
+ const ttl: number = cache.getTTL(uri);
const { resolve, reject } = callback;
- if (Component === null) {
+
+ // No value found for ttl, serve component from cache if exists
+ if (ttl === -1) {
+ const Component = cache.get(uri);
+ if (Component === null) {
+ return uriRequest(uri, callback);
+ } else if (typeof Component === 'function') {
+ return resolve(Component);
+ }
+ }
+
+ // ignore cache in case of ttl is 0
+ // for any other value, check if cache needs to burst
+ if (ttl === 0 || Date.now() - cachedTimeStamp > ttl) {
+ cache.delete(uri);
return uriRequest(uri, callback);
- } else if (typeof Component === 'function') {
- return resolve(Component);
}
+
return reject(
new Error(`[RSC]: Component for uri "${uri}" could not be instantiated`)
);
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
new file mode 100644
index 0000000..f0d55a9
--- /dev/null
+++ b/src/utils/constants.ts
@@ -0,0 +1,5 @@
+export const RSCResponseHeaders = {
+ // Cache-Control: max-age=1000
+ // value should be in milliseconds
+ ttl: 'Cache-Control',
+};
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
new file mode 100644
index 0000000..2240c1e
--- /dev/null
+++ b/src/utils/utils.ts
@@ -0,0 +1,20 @@
+import { type RawAxiosResponseHeaders } from 'axios';
+import { RSCResponseHeaders } from './constants';
+
+export const getTTLFromResponseHeaders = (
+ headers: Partial
+): number => {
+ if (headers === undefined) {
+ return -1;
+ }
+ const cacheControlHeader = headers[RSCResponseHeaders.ttl] as string;
+ // Extract TTL from Cache-Control header
+ // Example: "max-age=1000"
+ // value is in milliseconds
+ const maxAgeMatch = cacheControlHeader?.match(/max-age=(\d+)/);
+ if (maxAgeMatch && maxAgeMatch.length > 1) {
+ return parseInt(maxAgeMatch[1], 10);
+ }
+ // Default TTL value if not specified in headers
+ return -1;
+};
diff --git a/yarn.lock b/yarn.lock
index 276c25d..43eb0f4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3132,7 +3132,7 @@ __metadata:
languageName: node
linkType: hard
-"@react-navigation/native-stack@npm:^6.9.22":
+"@react-navigation/native-stack@npm:^6.9.26":
version: 6.9.26
resolution: "@react-navigation/native-stack@npm:6.9.26"
dependencies:
@@ -3148,7 +3148,7 @@ __metadata:
languageName: node
linkType: hard
-"@react-navigation/native@npm:^6.1.14":
+"@react-navigation/native@npm:^6.1.17":
version: 6.1.17
resolution: "@react-navigation/native@npm:6.1.17"
dependencies:
@@ -11245,6 +11245,15 @@ __metadata:
languageName: node
linkType: hard
+"react-freeze@npm:^1.0.0":
+ version: 1.0.4
+ resolution: "react-freeze@npm:1.0.4"
+ peerDependencies:
+ react: ">=17.0.0"
+ checksum: 6b4d93209dff04a1f25d9f8e0c56a9a8a80e7889e8e8267299f34449c7e41a9ab90cad24569d03dd7173b56b7496576dba68f71f1d4e5c8be72f0633023668bc
+ languageName: node
+ linkType: hard
+
"react-is@npm:^16.12.0 || ^17.0.0 || ^18.0.0, react-is@npm:^18.0.0":
version: 18.2.0
resolution: "react-is@npm:18.2.0"
@@ -11295,6 +11304,29 @@ __metadata:
languageName: node
linkType: hard
+"react-native-safe-area-context@npm:^4.9.0":
+ version: 4.9.0
+ resolution: "react-native-safe-area-context@npm:4.9.0"
+ peerDependencies:
+ react: "*"
+ react-native: "*"
+ checksum: 120aefb77766a37e81c5a844d7e1493afea35461c30df5ca903c6b6e2fc6694e2496d433550a2eda872ef8384a36ecf8529a617cb19acbeebd37d78d7aa7b17e
+ languageName: node
+ linkType: hard
+
+"react-native-screens@npm:^3.29.0":
+ version: 3.30.1
+ resolution: "react-native-screens@npm:3.30.1"
+ dependencies:
+ react-freeze: ^1.0.0
+ warn-once: ^0.1.0
+ peerDependencies:
+ react: "*"
+ react-native: "*"
+ checksum: e6c9b69ec97a4e46dd335ed25312c3621546bf0959754338aabbd4a16cc9cfb259548c996d2d737ea772a7b2ce7f198ffcd2977673e74c141334ce94fa3861c6
+ languageName: node
+ linkType: hard
+
"react-native-server-component-example@workspace:example":
version: 0.0.0-use.local
resolution: "react-native-server-component-example@workspace:example"
@@ -11305,11 +11337,13 @@ __metadata:
"@react-native/babel-preset": 0.73.21
"@react-native/metro-config": 0.73.5
"@react-native/typescript-config": 0.73.1
- "@react-navigation/native": ^6.1.14
- "@react-navigation/native-stack": ^6.9.22
+ "@react-navigation/native": ^6.1.17
+ "@react-navigation/native-stack": ^6.9.26
babel-plugin-module-resolver: ^5.0.0
react: 18.2.0
react-native: 0.73.6
+ react-native-safe-area-context: ^4.9.0
+ react-native-screens: ^3.29.0
languageName: unknown
linkType: soft