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