Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WECA-1218: wait for empty JS call stack before calling view command #110

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions ios/FullStory.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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"]) { \
Expand All @@ -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
Expand Down
108 changes: 4 additions & 104 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -97,97 +86,6 @@ declare global {
const identifyWithProperties = (uid: string, userVars = {}) => identify(uid, userVars);

export { FSPage } from './FSPage';
type FSComponentType = HostComponent<NativeProps>;

interface NativeCommands {
fsClass: (viewRef: React.ElementRef<FSComponentType>, fsClass: string) => void;
fsAttribute: (viewRef: React.ElementRef<FSComponentType>, fsAttribute: object) => void;
fsTagName: (viewRef: React.ElementRef<FSComponentType>, fsTagName: string) => void;
dataElement: (viewRef: React.ElementRef<FSComponentType>, dataElement: string) => void;
dataSourceFile: (viewRef: React.ElementRef<FSComponentType>, dataElement: string) => void;
dataComponent: (viewRef: React.ElementRef<FSComponentType>, 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<NativeCommands>({
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<unknown>) {
return function (element: React.ElementRef<FSComponentType>) {
if (isTurboModuleEnabled && Platform.OS === 'ios') {
let currentProps: Record<keyof NativeCommands, string | object>;

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,
Expand All @@ -205,6 +103,8 @@ const FullStoryAPI: FullStoryStatic = {
LogLevel,
};

export { applyFSPropertiesWithRef };

export const PrivateInterface: FullStoryPrivateStatic =
Platform.OS === 'android' ? { onFSPressForward: FullStoryPrivate.onFSPressForward } : {};

Expand Down
120 changes: 120 additions & 0 deletions src/nativeCommands.ts
Original file line number Diff line number Diff line change
@@ -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<NativeProps>;

interface NativeCommands {
fsClass: (viewRef: React.ElementRef<FSComponentType>, fsClass: string) => void;
fsAttribute: (viewRef: React.ElementRef<FSComponentType>, fsAttribute: object) => void;
fsTagName: (viewRef: React.ElementRef<FSComponentType>, fsTagName: string) => void;
dataElement: (viewRef: React.ElementRef<FSComponentType>, dataElement: string) => void;
dataSourceFile: (viewRef: React.ElementRef<FSComponentType>, dataElement: string) => void;
dataComponent: (viewRef: React.ElementRef<FSComponentType>, 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<NativeCommands>({
supportedCommands: SUPPORTED_FS_ATTRIBUTES,
});

function callNativeCommands(
element: React.ElementRef<FSComponentType>,
currentProps: Record<keyof NativeCommands, string | object>,
) {
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<unknown>) {
return function (element: React.ElementRef<FSComponentType>) {
if (isTurboModuleEnabled && Platform.OS === 'ios') {
let currentProps: Record<keyof NativeCommands, string | object>;

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;
}
}
};
}