diff --git a/ios/FullStory.mm b/ios/FullStory.mm index e30bbc7..57819fa 100644 --- a/ios/FullStory.mm +++ b/ios/FullStory.mm @@ -330,12 +330,19 @@ - (void) set_fsAttribute:(id)json forView:(RCTView*)view withDefaultView:(RCTVie } @end +extern "C" { +extern BOOL _FS_MOUNTED_REACT_NATIVE_COMPONENT; +} + @interface FSReactSwizzleBootstrap : NSObject @end #define SWIZZLE_HANDLE_COMMAND(rct_clazz) \ SWIZZLE_BEGIN_INSTANCE(rct_clazz, @selector(handleCommand:args:), void, const NSString *commandName, const NSArray *args) { \ if ([commandName isEqualToString:@"fsAttribute"]) { \ set_fsAttribute(args[0], self); \ + } else if ([commandName isEqualToString:@"fsMounted"]) { + // set associated obj + objc_setAssociatedObject(self, &_FS_MOUNTED_REACT_NATIVE_COMPONENT, @TRUE, OBJC_ASSOCIATION_RETAIN); } else if ([commandName isEqualToString:@"fsClass"]) { \ set_fsClass(args[0], self); \ } else if ([commandName isEqualToString:@"dataElement"]) { \ @@ -361,6 +368,7 @@ static bool array_contains_string(const char **array, const char *string) { @implementation FSReactSwizzleBootstrap + (void) load { + _FS_MOUNTED_REACT_NATIVE_COMPONENT = YES; /* class_copyMethodList in RCTComponentData's lookup of NativeProps * can't see the propConfigs that we create in the superclass. So * we swizzle that to inject our NativeProps directly in there, so diff --git a/src/index.ts b/src/index.ts index a7e8ac8..99845a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,20 +1,9 @@ -import { HostComponent, NativeModules, Platform } from 'react-native'; -import codegenNativeCommands from 'react-native/Libraries/Utilities/codegenNativeCommands'; -import type { ViewProps } from 'react-native/Libraries/Components/View/ViewPropTypes'; -import { ForwardedRef } from 'react'; +import { NativeModules, Platform } from 'react-native'; +import { applyFSPropertiesWithRef } from './nativeCommands'; // @ts-expect-error const isTurboModuleEnabled = global.__turboModuleProxy != null; -interface NativeProps extends ViewProps { - fsClass?: string; - fsAttribute?: object; - fsTagName?: string; - dataElement?: string; - dataSourceFile?: string; - dataComponent?: string; -} - const FullStory = isTurboModuleEnabled ? require('./NativeFullStory').default : NativeModules.FullStory; @@ -97,97 +86,6 @@ declare global { const identifyWithProperties = (uid: string, userVars = {}) => identify(uid, userVars); export { FSPage } from './FSPage'; -type FSComponentType = HostComponent; - -interface NativeCommands { - fsClass: (viewRef: React.ElementRef, fsClass: string) => void; - fsAttribute: (viewRef: React.ElementRef, fsAttribute: object) => void; - fsTagName: (viewRef: React.ElementRef, fsTagName: string) => void; - dataElement: (viewRef: React.ElementRef, dataElement: string) => void; - dataSourceFile: (viewRef: React.ElementRef, dataElement: string) => void; - dataComponent: (viewRef: React.ElementRef, dataElement: string) => void; -} - -/* - Calling these commands sequentially will *not* lead to an intermediate state where views - have incomplete attribute values. React's rendering phases protects against this race condition. - See DOC-1863 for more information. -*/ -const SUPPORTED_FS_ATTRIBUTES = [ - 'fsClass', - 'fsAttribute', - 'fsTagName', - 'dataElement', - 'dataComponent', - 'dataSourceFile', -] as (keyof NativeCommands)[]; - -const Commands: NativeCommands = codegenNativeCommands({ - supportedCommands: SUPPORTED_FS_ATTRIBUTES, -}); - -let getInternalInstanceHandleFromPublicInstance: Function | undefined; - -try { - getInternalInstanceHandleFromPublicInstance = - require('react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance').getInternalInstanceHandleFromPublicInstance; -} catch (e) {} - -export function applyFSPropertiesWithRef(existingRef?: ForwardedRef) { - return function (element: React.ElementRef) { - if (isTurboModuleEnabled && Platform.OS === 'ios') { - let currentProps: Record; - - if (getInternalInstanceHandleFromPublicInstance && element) { - currentProps = - getInternalInstanceHandleFromPublicInstance(element)?.stateNode?.canonical.currentProps; - } else { - // https://github.com/facebook/react-native/blob/87d2ea9c364c7ea393d11718c195dfe580c916ef/packages/react-native/Libraries/Components/TextInput/TextInputState.js#L109C23-L109C67 - // @ts-expect-error `currentProps` is missing in `NativeMethods` - currentProps = element?.currentProps; - } - if (currentProps) { - const fsClass = currentProps.fsClass as string; - if (fsClass) { - Commands.fsClass(element, fsClass); - } - - const fsAttribute = currentProps.fsAttribute as object; - if (fsAttribute) { - Commands.fsAttribute(element, fsAttribute); - } - - const fsTagName = currentProps.fsTagName as string; - if (fsTagName) { - Commands.fsTagName(element, fsTagName); - } - - const dataElement = currentProps.dataElement as string; - if (dataElement) { - Commands.dataElement(element, dataElement); - } - - const dataComponent = currentProps.dataComponent as string; - if (dataComponent) { - Commands.dataComponent(element, dataComponent); - } - - const dataSourceFile = currentProps.dataSourceFile as string; - if (dataSourceFile) { - Commands.dataSourceFile(element, dataSourceFile); - } - } - } - - if (existingRef) { - if (existingRef instanceof Function) { - existingRef(element); - } else { - existingRef.current = element; - } - } - }; -} const FullStoryAPI: FullStoryStatic = { anonymize, @@ -205,6 +103,8 @@ const FullStoryAPI: FullStoryStatic = { LogLevel, }; +export { applyFSPropertiesWithRef }; + export const PrivateInterface: FullStoryPrivateStatic = Platform.OS === 'android' ? { onFSPressForward: FullStoryPrivate.onFSPressForward } : {}; diff --git a/src/nativeCommands.ts b/src/nativeCommands.ts new file mode 100644 index 0000000..6bfb5af --- /dev/null +++ b/src/nativeCommands.ts @@ -0,0 +1,120 @@ +import { ForwardedRef } from 'react'; +import { HostComponent, Platform } from 'react-native'; +import type { ViewProps } from 'react-native/Libraries/Components/View/ViewPropTypes'; +import codegenNativeCommands from 'react-native/Libraries/Utilities/codegenNativeCommands'; + +// @ts-expect-error +const isTurboModuleEnabled = global.__turboModuleProxy != null; + +interface NativeProps extends ViewProps { + fsClass?: string; + fsAttribute?: object; + fsTagName?: string; + dataElement?: string; + dataSourceFile?: string; + dataComponent?: string; +} + +type FSComponentType = HostComponent; + +interface NativeCommands { + fsClass: (viewRef: React.ElementRef, fsClass: string) => void; + fsAttribute: (viewRef: React.ElementRef, fsAttribute: object) => void; + fsTagName: (viewRef: React.ElementRef, fsTagName: string) => void; + dataElement: (viewRef: React.ElementRef, dataElement: string) => void; + dataSourceFile: (viewRef: React.ElementRef, dataElement: string) => void; + dataComponent: (viewRef: React.ElementRef, dataElement: string) => void; +} + +/* + Calling these commands sequentially will *not* lead to an intermediate state where views + have incomplete attribute values. React's rendering phases protects against this race condition. + See DOC-1863 for more information. +*/ +const SUPPORTED_FS_ATTRIBUTES = [ + 'fsClass', + 'fsAttribute', + 'fsTagName', + 'dataElement', + 'dataComponent', + 'dataSourceFile', +] as (keyof NativeCommands)[]; + +const Commands: NativeCommands = codegenNativeCommands({ + supportedCommands: SUPPORTED_FS_ATTRIBUTES, +}); + +function callNativeCommands( + element: React.ElementRef, + currentProps: Record, +) { + const fsClass = currentProps.fsClass as string; + if (fsClass) { + Commands.fsClass(element, fsClass); + } + + const fsAttribute = currentProps.fsAttribute as object; + if (fsAttribute) { + Commands.fsAttribute(element, fsAttribute); + } + + const fsTagName = currentProps.fsTagName as string; + if (fsTagName) { + Commands.fsTagName(element, fsTagName); + } + + const dataElement = currentProps.dataElement as string; + if (dataElement) { + Commands.dataElement(element, dataElement); + } + + const dataComponent = currentProps.dataComponent as string; + if (dataComponent) { + Commands.dataComponent(element, dataComponent); + } + + const dataSourceFile = currentProps.dataSourceFile as string; + if (dataSourceFile) { + Commands.dataSourceFile(element, dataSourceFile); + } +} + +let getInternalInstanceHandleFromPublicInstance: Function | undefined; + +try { + getInternalInstanceHandleFromPublicInstance = + require('react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance').getInternalInstanceHandleFromPublicInstance; +} catch (e) {} + +export function applyFSPropertiesWithRef(existingRef?: ForwardedRef) { + return function (element: React.ElementRef) { + if (isTurboModuleEnabled && Platform.OS === 'ios') { + let currentProps: Record; + + if (getInternalInstanceHandleFromPublicInstance && element) { + currentProps = + getInternalInstanceHandleFromPublicInstance(element)?.stateNode?.canonical.currentProps; + } else { + // https://github.com/facebook/react-native/blob/87d2ea9c364c7ea393d11718c195dfe580c916ef/packages/react-native/Libraries/Components/TextInput/TextInputState.js#L109C23-L109C67 + // @ts-expect-error `currentProps` is missing in `NativeMethods` + currentProps = element?.currentProps; + } + if (currentProps) { + /* + Delay native command execution until JS mount operations in the call stack is flushed. + Issue link: https://github.com/facebook/react-native/issues/47576 + TODO: Version check the setTimeout once the above issue is resolved + */ + setTimeout(() => callNativeCommands(element, currentProps), 0); + } + } + + if (existingRef) { + if (existingRef instanceof Function) { + existingRef(element); + } else { + existingRef.current = element; + } + } + }; +}