@momo-kits/foundation 0.169.7-beta.1 → 0.169.7-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Application/BottomSheet.hooks.ts +8 -8
- package/Application/BottomSheet.parts.tsx +34 -44
- package/Application/BottomSheet.tsx +37 -40
- package/Application/BottomTab/ActiveIndicator.tsx +14 -15
- package/Application/BottomTab/BottomTabBar.styles.ts +1 -18
- package/Application/BottomTab/BottomTabBar.tsx +72 -57
- package/Application/BottomTab/BottomTabInset.tsx +4 -8
- package/Application/BottomTab/CurvedContainer.tsx +14 -22
- package/Application/BottomTab/CustomBottomTabItem.tsx +15 -15
- package/Application/BottomTab/DuoBarCompression.tsx +19 -13
- package/Application/BottomTab/DuoRailTabBar.tsx +24 -20
- package/Application/BottomTab/DuoTabBar.tsx +13 -23
- package/Application/BottomTab/TabBarIcon.tsx +29 -15
- package/Application/BottomTab/TabScreen.tsx +5 -1
- package/Application/BottomTab/index.tsx +30 -33
- package/Application/BottomTab/metrics.ts +26 -46
- package/Application/BottomTab/tabItems.ts +1 -1
- package/Application/BottomTab/usePressLift.ts +2 -2
- package/Application/Components/BackgroundImageView.tsx +0 -4
- package/Application/Components/DuoGlassBackground.tsx +4 -5
- package/Application/Components/HeaderAnimated.tsx +0 -4
- package/Application/Components/HeaderBackground.tsx +0 -4
- package/Application/Components/HeaderExtendHeader.tsx +10 -11
- package/Application/Components/HeaderLeft.tsx +1 -2
- package/Application/Components/HeaderRight.tsx +1 -2
- package/Application/Components/HeaderTitle.shared.ts +9 -11
- package/Application/Components/HeaderTitle.tsx +3 -4
- package/Application/Components/HeaderToolkitAction.tsx +20 -17
- package/Application/Components/NavigationButton.tsx +1 -2
- package/Application/Components/SearchHeader.tsx +10 -17
- package/Application/Components/TitleJourney.tsx +2 -2
- package/Application/Components/TitleLocation.tsx +2 -2
- package/Application/Components/TitleUser.tsx +2 -2
- package/Application/Components/useToolkitActions.tsx +32 -17
- package/Application/KitAdoption/AdoptionBadge.tsx +119 -0
- package/Application/KitAdoption/AdoptionProbe.tsx +166 -0
- package/Application/KitAdoption/AdoptionStore.ts +80 -0
- package/Application/KitAdoption/Authorship/utils.ts +137 -0
- package/Application/KitAdoption/Slots/utils.ts +68 -0
- package/Application/KitAdoption/constants.ts +120 -0
- package/Application/KitAdoption/index.ts +12 -0
- package/Application/KitAdoption/styles.ts +55 -0
- package/Application/KitAdoption/types.ts +61 -0
- package/Application/KitAdoption/utils.ts +376 -0
- package/Application/Localize.ts +0 -7
- package/Application/ModalScreen.tsx +10 -10
- package/Application/Navigation.ts +16 -14
- package/Application/NavigationContainer.tsx +18 -19
- package/Application/Navigator.ts +48 -20
- package/Application/ScaleSizeProvider.tsx +6 -13
- package/Application/StackScreen.tracking.ts +33 -11
- package/Application/StackScreen.tsx +54 -10
- package/Application/TooltipPortal.tsx +5 -12
- package/Application/WidgetContainer.tsx +4 -10
- package/Application/__tests__/KitAdoption.integration.test.tsx +253 -0
- package/Application/__tests__/KitAdoption.test.ts +283 -0
- package/Application/__tests__/KitAdoptionAuthorship.test.ts +132 -0
- package/Application/__tests__/KitAdoptionStore.test.ts +110 -0
- package/Application/__tests__/KitAdoptionStructural.test.ts +193 -0
- package/Application/__tests__/modalLayout.test.ts +128 -0
- package/Application/modalLayout.ts +14 -13
- package/Application/navigationOptions.tsx +17 -21
- package/Application/types.tabs.ts +1 -0
- package/Badge/Badge.tsx +0 -5
- package/Badge/BadgeDot.tsx +0 -1
- package/Badge/BadgeDotAnimation.tsx +7 -15
- package/Badge/BadgeRibbon.tsx +38 -46
- package/Badge/Shape.tsx +0 -2
- package/Badge/types.ts +2 -2
- package/Button/index.tsx +16 -5
- package/Button/utils.ts +15 -9
- package/CheckBox/index.tsx +0 -4
- package/Consts/theme.ts +10 -10
- package/Context/index.ts +1 -5
- package/Divider/DashDivider.tsx +0 -1
- package/Divider/index.tsx +0 -4
- package/FoundationList/index.tsx +0 -4
- package/Icon/index.tsx +6 -6
- package/Icon/types.ts +1 -1
- package/IconButton/index.tsx +12 -4
- package/Image/index.tsx +7 -9
- package/Input/Input.tsx +9 -8
- package/Input/InputDropDown.tsx +12 -10
- package/Input/InputMoney.tsx +8 -12
- package/Input/InputMoneyView.tsx +7 -14
- package/Input/InputOTP.tsx +33 -35
- package/Input/InputPhoneNumber.tsx +9 -8
- package/Input/InputSearch.tsx +6 -4
- package/Input/InputTextArea.tsx +0 -4
- package/Input/SearchTextTyping.tsx +0 -4
- package/Input/SystemTextInput.tsx +2 -7
- package/Input/TextTyping.tsx +0 -4
- package/Input/common.tsx +12 -26
- package/Input/index.tsx +20 -17
- package/Input/moneyUtils.ts +4 -10
- package/Input/styles.ts +5 -0
- package/Input/utils.ts +12 -14
- package/Layout/Card.tsx +4 -7
- package/Layout/FloatingButton.tsx +0 -4
- package/Layout/Item.tsx +2 -6
- package/Layout/ItemList.tsx +7 -9
- package/Layout/ItemSectionList.tsx +1 -2
- package/Layout/ReservedRegions.tsx +25 -25
- package/Layout/Screen.tsx +58 -56
- package/Layout/Section.tsx +5 -8
- package/Layout/TrackingScope.tsx +0 -1
- package/Layout/screenContent.tsx +2 -4
- package/Layout/screenFooter.tsx +18 -28
- package/Layout/screenHeader.tsx +31 -34
- package/Layout/screenQuickReturn.tsx +20 -17
- package/Layout/screenScroll.ts +7 -4
- package/Layout/screenTypes.ts +2 -3
- package/Layout/types.ts +4 -2
- package/Layout/utils.ts +37 -31
- package/Loader/DotLoader.tsx +0 -4
- package/Loader/Loader.tsx +0 -4
- package/Loader/ProgressBar.tsx +0 -1
- package/Loader/Spinner.tsx +0 -4
- package/Loader/utils.ts +7 -11
- package/Pagination/Dot.tsx +0 -4
- package/Pagination/PaginationDot.tsx +0 -1
- package/Pagination/PaginationNumber.tsx +3 -5
- package/Pagination/PaginationScroll.tsx +0 -5
- package/Pagination/PaginationWhiteDot.tsx +0 -1
- package/Pagination/index.tsx +0 -4
- package/Pagination/types.ts +2 -2
- package/Popup/PopupNotify.tsx +48 -44
- package/Popup/PopupPromotion.tsx +7 -4
- package/Popup/types.ts +4 -4
- package/Popup/utils.ts +0 -4
- package/Radio/index.tsx +0 -4
- package/Skeleton/index.tsx +0 -8
- package/Switch/index.tsx +0 -1
- package/Tag/index.tsx +1 -5
- package/Tag/types.ts +3 -3
- package/Text/index.tsx +3 -14
- package/Text/styles.ts +1 -1
- package/Text/types.ts +6 -1
- package/Text/utils.ts +4 -20
- package/Title/index.tsx +32 -41
- package/ViewBoundary/index.tsx +2 -4
- package/index.ts +6 -0
- package/package.json +1 -1
|
@@ -15,8 +15,7 @@ type ToolkitActionsParams = {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
* with the home), single tool or "more" shortcut, help center and close.
|
|
18
|
+
* state and handlers behind the header toolkit buttons
|
|
20
19
|
*/
|
|
21
20
|
const useToolkitActions = ({
|
|
22
21
|
preventClose,
|
|
@@ -32,15 +31,20 @@ const useToolkitActions = ({
|
|
|
32
31
|
const [isFavorite, setIsFavorite] = useState<boolean>(false);
|
|
33
32
|
const [isLoading, setIsLoading] = useState(false);
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
/**
|
|
35
|
+
* check app is favorite
|
|
36
|
+
*/
|
|
37
|
+
const checkAppIsFavorite = useCallback(
|
|
38
|
+
() =>
|
|
39
|
+
navigator?.maxApi?.dispatchFunction?.(
|
|
40
|
+
'isFavoriteApp',
|
|
41
|
+
{ code: context?.code },
|
|
42
|
+
(result: boolean) => {
|
|
43
|
+
setIsFavorite(result);
|
|
44
|
+
},
|
|
45
|
+
),
|
|
46
|
+
[context?.code, navigator?.maxApi],
|
|
47
|
+
);
|
|
44
48
|
|
|
45
49
|
useEffect(() => {
|
|
46
50
|
if (useShortcut) {
|
|
@@ -71,6 +75,9 @@ const useToolkitActions = ({
|
|
|
71
75
|
}
|
|
72
76
|
};
|
|
73
77
|
|
|
78
|
+
/**
|
|
79
|
+
* close navigation container
|
|
80
|
+
*/
|
|
74
81
|
const onClose = () => {
|
|
75
82
|
const currentRoute = navigator?.ref?.current?.getCurrentRoute?.();
|
|
76
83
|
context?.autoTracking?.({
|
|
@@ -100,6 +107,9 @@ const useToolkitActions = ({
|
|
|
100
107
|
}
|
|
101
108
|
};
|
|
102
109
|
|
|
110
|
+
/**
|
|
111
|
+
* on press shortcut
|
|
112
|
+
*/
|
|
103
113
|
const onPressShortcut = () => {
|
|
104
114
|
const currentRoute = navigator?.ref?.current?.getCurrentRoute?.();
|
|
105
115
|
const expected = !isFavorite;
|
|
@@ -129,6 +139,9 @@ const useToolkitActions = ({
|
|
|
129
139
|
}, 1000);
|
|
130
140
|
};
|
|
131
141
|
|
|
142
|
+
/**
|
|
143
|
+
* on press help center
|
|
144
|
+
*/
|
|
132
145
|
const onPressHelpCenter = () => {
|
|
133
146
|
const currentRoute = navigator?.ref?.current?.getCurrentRoute?.();
|
|
134
147
|
context?.autoTracking?.({
|
|
@@ -141,6 +154,9 @@ const useToolkitActions = ({
|
|
|
141
154
|
navigator?.maxApi?.dispatchFunction?.('showHelpCenter', context);
|
|
142
155
|
};
|
|
143
156
|
|
|
157
|
+
/**
|
|
158
|
+
* on press icon more
|
|
159
|
+
*/
|
|
144
160
|
const onPressIconMore = () => {
|
|
145
161
|
const currentRoute = navigator?.ref?.current?.getCurrentRoute?.();
|
|
146
162
|
context?.autoTracking?.({
|
|
@@ -184,10 +200,9 @@ const useToolkitActions = ({
|
|
|
184
200
|
iconShortcut = 'navigation_more_icon';
|
|
185
201
|
shortcutOnPress = onPressIconMore;
|
|
186
202
|
} else if (isHaveOneTool) {
|
|
187
|
-
const
|
|
203
|
+
const singleTool = tools.find(
|
|
188
204
|
(group: ToolGroup) => group?.items?.length === 1,
|
|
189
|
-
);
|
|
190
|
-
const singleTool = singleToolGroup?.items[0];
|
|
205
|
+
)?.items[0];
|
|
191
206
|
iconShortcut = singleTool?.icon;
|
|
192
207
|
shortcutOnPress = () => {
|
|
193
208
|
singleTool?.onPress?.();
|
|
@@ -201,9 +216,9 @@ const useToolkitActions = ({
|
|
|
201
216
|
};
|
|
202
217
|
}
|
|
203
218
|
|
|
204
|
-
const showBadge = tools.some((group: ToolGroup) =>
|
|
205
|
-
|
|
206
|
-
|
|
219
|
+
const showBadge = tools.some((group: ToolGroup) =>
|
|
220
|
+
group?.items?.some?.(tool => tool?.showBadge),
|
|
221
|
+
);
|
|
207
222
|
|
|
208
223
|
return {
|
|
209
224
|
isLoading,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React, { useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import { Share, TouchableOpacity, View } from 'react-native';
|
|
3
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
4
|
+
|
|
5
|
+
import { Colors, Spacing } from '../../Consts';
|
|
6
|
+
import { Text } from '../../Text';
|
|
7
|
+
import { adoptionStore } from './AdoptionStore';
|
|
8
|
+
import { styles } from './styles';
|
|
9
|
+
import type { ScreenSnapshot } from './types';
|
|
10
|
+
|
|
11
|
+
/** Subscribes a React listener to the adoption store. Stable identity for useSyncExternalStore. */
|
|
12
|
+
const subscribeToStore = (listener: () => void): (() => void) =>
|
|
13
|
+
adoptionStore.subscribe(listener);
|
|
14
|
+
|
|
15
|
+
const getLatestSnapshot = (): ScreenSnapshot | undefined =>
|
|
16
|
+
adoptionStore.getLatest();
|
|
17
|
+
|
|
18
|
+
const getScreenCount = (): number => adoptionStore.getAll().length;
|
|
19
|
+
|
|
20
|
+
/** Share of classified nodes that are app-authored kit: K / (K + R + O). */
|
|
21
|
+
const getAdoptionRate = (snapshot: ScreenSnapshot): number => {
|
|
22
|
+
const { K, R, O } = snapshot.counts;
|
|
23
|
+
const total = K + R + O;
|
|
24
|
+
return total === 0 ? 0 : K / total;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const formatSummary = (snapshot: ScreenSnapshot | undefined): string => {
|
|
28
|
+
if (!snapshot) return 'Kit adoption: chưa có dữ liệu';
|
|
29
|
+
const { K, R, O } = snapshot.counts;
|
|
30
|
+
const percent = Math.round(getAdoptionRate(snapshot) * 100);
|
|
31
|
+
return `Kit ${percent}% · K${K} R${R} O${O}`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const handleRemeasure = (): void => {
|
|
35
|
+
adoptionStore.remeasureActive();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const handleShare = (): void => {
|
|
39
|
+
Share.share({ message: JSON.stringify(adoptionStore.getAll()) }).catch(
|
|
40
|
+
() => undefined,
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const handleClear = (): void => {
|
|
45
|
+
adoptionStore.clear();
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Dev-only floating badge showing the latest screen's kit adoption. Tap to
|
|
50
|
+
* expand actions (re-measure, share JSON, clear). Renders nothing in release builds.
|
|
51
|
+
*/
|
|
52
|
+
const AdoptionBadge: React.FC = () => {
|
|
53
|
+
const insets = useSafeAreaInsets();
|
|
54
|
+
const [isExpanded, setExpanded] = useState(false);
|
|
55
|
+
const latest = useSyncExternalStore(subscribeToStore, getLatestSnapshot);
|
|
56
|
+
const screenCount = useSyncExternalStore(subscribeToStore, getScreenCount);
|
|
57
|
+
|
|
58
|
+
if (!__DEV__) return null;
|
|
59
|
+
|
|
60
|
+
const handleToggle = (): void => setExpanded(prev => !prev);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<View
|
|
64
|
+
pointerEvents="box-none"
|
|
65
|
+
style={[styles.wrapper, { top: insets.top + Spacing.S }]}
|
|
66
|
+
>
|
|
67
|
+
<TouchableOpacity
|
|
68
|
+
accessibilityRole="button"
|
|
69
|
+
onPress={handleToggle}
|
|
70
|
+
style={styles.badge}
|
|
71
|
+
>
|
|
72
|
+
<Text typography="label_xs_medium" color={Colors.black_01}>
|
|
73
|
+
{formatSummary(latest)}
|
|
74
|
+
</Text>
|
|
75
|
+
{latest ? (
|
|
76
|
+
<Text typography="description_xs_regular" color={Colors.black_01}>
|
|
77
|
+
{latest.screen_name}
|
|
78
|
+
</Text>
|
|
79
|
+
) : null}
|
|
80
|
+
</TouchableOpacity>
|
|
81
|
+
{isExpanded ? (
|
|
82
|
+
<View style={styles.panel}>
|
|
83
|
+
<Text typography="label_xs_medium" color={Colors.black_17}>
|
|
84
|
+
{`Đã đo ${screenCount} màn hình`}
|
|
85
|
+
</Text>
|
|
86
|
+
<TouchableOpacity
|
|
87
|
+
accessibilityRole="button"
|
|
88
|
+
onPress={handleRemeasure}
|
|
89
|
+
style={styles.action}
|
|
90
|
+
>
|
|
91
|
+
<Text typography="label_xs_medium" color={Colors.blue_03}>
|
|
92
|
+
Đo lại
|
|
93
|
+
</Text>
|
|
94
|
+
</TouchableOpacity>
|
|
95
|
+
<TouchableOpacity
|
|
96
|
+
accessibilityRole="button"
|
|
97
|
+
onPress={handleShare}
|
|
98
|
+
style={styles.action}
|
|
99
|
+
>
|
|
100
|
+
<Text typography="label_xs_medium" color={Colors.blue_03}>
|
|
101
|
+
Share JSON
|
|
102
|
+
</Text>
|
|
103
|
+
</TouchableOpacity>
|
|
104
|
+
<TouchableOpacity
|
|
105
|
+
accessibilityRole="button"
|
|
106
|
+
onPress={handleClear}
|
|
107
|
+
style={styles.actionOutline}
|
|
108
|
+
>
|
|
109
|
+
<Text typography="label_xs_medium" color={Colors.black_17}>
|
|
110
|
+
Xoá
|
|
111
|
+
</Text>
|
|
112
|
+
</TouchableOpacity>
|
|
113
|
+
</View>
|
|
114
|
+
) : null}
|
|
115
|
+
</View>
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export { AdoptionBadge };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { InteractionManager, Platform } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { MiniAppContext } from '../../Context';
|
|
5
|
+
import { adoptionStore } from './AdoptionStore';
|
|
6
|
+
import { SETTLE_MS } from './constants';
|
|
7
|
+
import type {
|
|
8
|
+
AdoptionMeasureRef,
|
|
9
|
+
FiberLike,
|
|
10
|
+
KitRegistries,
|
|
11
|
+
ScreenSnapshot,
|
|
12
|
+
WalkResult,
|
|
13
|
+
} from './types';
|
|
14
|
+
import {
|
|
15
|
+
createRegistries,
|
|
16
|
+
getAppId,
|
|
17
|
+
getCurrentFiber,
|
|
18
|
+
isAdoptionTrackingEnabled,
|
|
19
|
+
walkScreen,
|
|
20
|
+
} from './utils';
|
|
21
|
+
|
|
22
|
+
type NavigationLike = {
|
|
23
|
+
addListener?: (event: string, callback: () => void) => () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type ProbeProps = {
|
|
27
|
+
screenName: string;
|
|
28
|
+
navigation?: NavigationLike;
|
|
29
|
+
measureRef?: AdoptionMeasureRef;
|
|
30
|
+
children: React.ReactNode;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let registriesCache: KitRegistries | undefined;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Kit and RN identity sets, built on first use. Lazy require breaks the cycle
|
|
37
|
+
* between the foundation index and the Application screens that render this probe.
|
|
38
|
+
*/
|
|
39
|
+
const getRegistries = (): KitRegistries => {
|
|
40
|
+
if (!registriesCache) {
|
|
41
|
+
const kitModule = require('../../index') as Record<string, unknown>;
|
|
42
|
+
const rnModule = require('react-native') as Record<string, unknown>;
|
|
43
|
+
registriesCache = createRegistries([kitModule], rnModule);
|
|
44
|
+
}
|
|
45
|
+
return registriesCache;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Foundation version from its package.json, or undefined if it cannot be read. */
|
|
49
|
+
const readFoundationVersion = (): string | undefined => {
|
|
50
|
+
try {
|
|
51
|
+
const pkg = require('../../package.json') as { version?: string };
|
|
52
|
+
return pkg.version;
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const createErrorResult = (): WalkResult => ({
|
|
59
|
+
counts: { K: 0, R: 0, O: 0 },
|
|
60
|
+
kit: {},
|
|
61
|
+
rn: {},
|
|
62
|
+
other: {},
|
|
63
|
+
top_transparent: [],
|
|
64
|
+
fibers: 0,
|
|
65
|
+
truncated: false,
|
|
66
|
+
status: 'error',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** Stops the walk at nested probes so each screen is measured on its own. */
|
|
70
|
+
const isProbeFiber = (fiber: FiberLike): boolean =>
|
|
71
|
+
fiber.type === AdoptionProbeDev;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Wrapper around a screen. In dev it measures the fiber tree after the first
|
|
75
|
+
* interactions settle and on focus; when tracking is enabled it exposes a measure for screen-load events.
|
|
76
|
+
*/
|
|
77
|
+
class AdoptionProbeDev extends React.Component<ProbeProps> {
|
|
78
|
+
static contextType = MiniAppContext;
|
|
79
|
+
|
|
80
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
81
|
+
private interaction: { cancel: () => void } | undefined;
|
|
82
|
+
private unsubscribeFocus: (() => void) | undefined;
|
|
83
|
+
|
|
84
|
+
componentDidMount(): void {
|
|
85
|
+
const { measureRef } = this.props;
|
|
86
|
+
if (measureRef && (__DEV__ || isAdoptionTrackingEnabled(this.context))) {
|
|
87
|
+
measureRef.current = this.measureForTracking;
|
|
88
|
+
}
|
|
89
|
+
if (!__DEV__) return;
|
|
90
|
+
adoptionStore.setActive(this.measure);
|
|
91
|
+
this.unsubscribeFocus = this.props.navigation?.addListener?.(
|
|
92
|
+
'focus',
|
|
93
|
+
this.handleFocus,
|
|
94
|
+
);
|
|
95
|
+
this.interaction = InteractionManager.runAfterInteractions(() => {
|
|
96
|
+
this.timer = setTimeout(this.measure, SETTLE_MS);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
componentWillUnmount(): void {
|
|
101
|
+
const { measureRef } = this.props;
|
|
102
|
+
if (measureRef?.current === this.measureForTracking) {
|
|
103
|
+
measureRef.current = undefined;
|
|
104
|
+
}
|
|
105
|
+
if (!__DEV__) return;
|
|
106
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
107
|
+
this.interaction?.cancel();
|
|
108
|
+
this.unsubscribeFocus?.();
|
|
109
|
+
adoptionStore.clearActive(this.measure);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
render(): React.ReactNode {
|
|
113
|
+
return this.props.children;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private handleFocus = (): void => {
|
|
117
|
+
adoptionStore.setActive(this.measure);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
private measure = (): void => {
|
|
121
|
+
const startedAt = Date.now();
|
|
122
|
+
let result: WalkResult | undefined;
|
|
123
|
+
try {
|
|
124
|
+
result = this.walk();
|
|
125
|
+
} catch {
|
|
126
|
+
result = createErrorResult();
|
|
127
|
+
}
|
|
128
|
+
if (result) this.save(result, startedAt);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Measures without saving to the store; undefined on error or when there is no content. */
|
|
132
|
+
private measureForTracking = (): WalkResult | undefined => {
|
|
133
|
+
try {
|
|
134
|
+
return this.walk();
|
|
135
|
+
} catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
private walk(): WalkResult | undefined {
|
|
141
|
+
const self = getCurrentFiber(
|
|
142
|
+
(this as unknown as { _reactInternals: FiberLike })._reactInternals,
|
|
143
|
+
);
|
|
144
|
+
const root = self.child;
|
|
145
|
+
if (!root) return undefined;
|
|
146
|
+
return walkScreen(root, getRegistries(), isProbeFiber, undefined, !__DEV__);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private save(result: WalkResult, startedAt: number): void {
|
|
150
|
+
const snapshot: ScreenSnapshot = {
|
|
151
|
+
...result,
|
|
152
|
+
app_id: getAppId(this.context),
|
|
153
|
+
screen_name: this.props.screenName,
|
|
154
|
+
foundation_version: readFoundationVersion(),
|
|
155
|
+
platform: Platform.OS,
|
|
156
|
+
ts: Date.now(),
|
|
157
|
+
walk_ms: Date.now() - startedAt,
|
|
158
|
+
};
|
|
159
|
+
adoptionStore.save(snapshot);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const AdoptionProbe = AdoptionProbeDev;
|
|
164
|
+
|
|
165
|
+
export type { NavigationLike, ProbeProps };
|
|
166
|
+
export { AdoptionProbe, AdoptionProbeDev };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ScreenSnapshot } from './types';
|
|
2
|
+
|
|
3
|
+
type Listener = () => void;
|
|
4
|
+
type Measure = () => void;
|
|
5
|
+
|
|
6
|
+
/** Total classified nodes in a snapshot, used to keep the richer measurement. */
|
|
7
|
+
const getTotal = (snapshot: ScreenSnapshot): number =>
|
|
8
|
+
snapshot.counts.K + snapshot.counts.R + snapshot.counts.O;
|
|
9
|
+
|
|
10
|
+
const getKey = (snapshot: ScreenSnapshot): string =>
|
|
11
|
+
`${snapshot.app_id}/${snapshot.screen_name}`;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* In-memory, dev-only store of per-screen snapshots. Keeps one snapshot per
|
|
15
|
+
* app and screen (the one with the most nodes) and holds the active measurer.
|
|
16
|
+
*/
|
|
17
|
+
class AdoptionStore {
|
|
18
|
+
private snapshots = new Map<string, ScreenSnapshot>();
|
|
19
|
+
private listeners = new Set<Listener>();
|
|
20
|
+
private latestKey: string | undefined;
|
|
21
|
+
private activeMeasure: Measure | undefined;
|
|
22
|
+
|
|
23
|
+
/** Stores a snapshot unless the stored one for the same screen is larger. */
|
|
24
|
+
save(snapshot: ScreenSnapshot): void {
|
|
25
|
+
const key = getKey(snapshot);
|
|
26
|
+
const existing = this.snapshots.get(key);
|
|
27
|
+
if (!existing || getTotal(existing) <= getTotal(snapshot)) {
|
|
28
|
+
this.snapshots.set(key, snapshot);
|
|
29
|
+
}
|
|
30
|
+
this.latestKey = key;
|
|
31
|
+
this.notify();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getAll(): ScreenSnapshot[] {
|
|
35
|
+
return Array.from(this.snapshots.values());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The screen saved most recently, or undefined when nothing is saved. */
|
|
39
|
+
getLatest(): ScreenSnapshot | undefined {
|
|
40
|
+
return this.latestKey === undefined
|
|
41
|
+
? undefined
|
|
42
|
+
: this.snapshots.get(this.latestKey);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
clear(): void {
|
|
46
|
+
this.snapshots.clear();
|
|
47
|
+
this.latestKey = undefined;
|
|
48
|
+
this.notify();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Registers a change listener and returns its unsubscribe function. */
|
|
52
|
+
subscribe(listener: Listener): () => void {
|
|
53
|
+
this.listeners.add(listener);
|
|
54
|
+
return () => {
|
|
55
|
+
this.listeners.delete(listener);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setActive(measure: Measure): void {
|
|
60
|
+
this.activeMeasure = measure;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Clears the active measurer only if it is still the given one. */
|
|
64
|
+
clearActive(measure: Measure): void {
|
|
65
|
+
if (this.activeMeasure === measure) this.activeMeasure = undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
remeasureActive(): void {
|
|
69
|
+
this.activeMeasure?.();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private notify(): void {
|
|
73
|
+
this.listeners.forEach(listener => listener());
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const adoptionStore = new AdoptionStore();
|
|
78
|
+
|
|
79
|
+
export type { Listener, Measure };
|
|
80
|
+
export { AdoptionStore, adoptionStore };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FIBER_TAG,
|
|
3
|
+
LOCAL_SOURCE_KEY,
|
|
4
|
+
REACT_FACTORY_FRAME_NAMES,
|
|
5
|
+
REACT_STACK_BOTTOM_FRAME,
|
|
6
|
+
} from '../constants';
|
|
7
|
+
import type { FiberLike } from '../types';
|
|
8
|
+
|
|
9
|
+
const HTTP_ORIGIN_PATTERN = /^(https?:\/\/[^/]+)/i;
|
|
10
|
+
const FILE_URL_PATTERN = /^file:\/\//i;
|
|
11
|
+
const LINE_COL_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
|
|
12
|
+
const PAREN_LOCATION_PATTERN = /\((?:address at )?(.+)\)\s*$/;
|
|
13
|
+
const BARE_LOCATION_PATTERN = /^\s*at\s+(.+?)\s*$/;
|
|
14
|
+
const FRAME_NAME_PATTERN = /^\s*at\s+([^\s(]+)/;
|
|
15
|
+
const NATIVE_FRAME_LOCATION = 'native';
|
|
16
|
+
|
|
17
|
+
const creatorSourceCache = new WeakMap<object, string | null>();
|
|
18
|
+
let hostSource: string | undefined;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Bundle identity of a frame location: the origin for http(s), the directory
|
|
22
|
+
* for file URLs, and one shared key for bare source paths (jest).
|
|
23
|
+
*/
|
|
24
|
+
const getSourceKey = (location: string): string => {
|
|
25
|
+
const origin = HTTP_ORIGIN_PATTERN.exec(location);
|
|
26
|
+
if (origin) return origin[1];
|
|
27
|
+
if (!FILE_URL_PATTERN.test(location)) return LOCAL_SOURCE_KEY;
|
|
28
|
+
const path = location.replace(LINE_COL_SUFFIX_PATTERN, '');
|
|
29
|
+
return path.slice(0, path.lastIndexOf('/'));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Location (`url:line:col`) of a stack frame line, or undefined when it has none. */
|
|
33
|
+
const parseFrameLocation = (line: string): string | undefined => {
|
|
34
|
+
const match =
|
|
35
|
+
PAREN_LOCATION_PATTERN.exec(line) ?? BARE_LOCATION_PATTERN.exec(line);
|
|
36
|
+
return match?.[1];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Reads `.stack` of a debug stack value without throwing; undefined if it is not a string. */
|
|
40
|
+
const readStack = (debugStack: unknown): string | undefined => {
|
|
41
|
+
try {
|
|
42
|
+
const stack = (debugStack as { stack?: unknown } | null)?.stack;
|
|
43
|
+
return typeof stack === 'string' ? stack : undefined;
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Frame lines below the header, cut at React's bottom-frame marker. */
|
|
50
|
+
const getFrameLines = (stack: string): string[] => {
|
|
51
|
+
const lines = stack.split('\n').slice(1);
|
|
52
|
+
const bottom = lines.findIndex(line =>
|
|
53
|
+
line.includes(REACT_STACK_BOTTOM_FRAME),
|
|
54
|
+
);
|
|
55
|
+
return bottom === -1 ? lines : lines.slice(0, bottom);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True for frames that cannot be the element's creator: React's own factory
|
|
60
|
+
* wrappers and native frames (e.g. `apply (native)`), which carry no bundle.
|
|
61
|
+
*/
|
|
62
|
+
const isSkippedFrame = (line: string): boolean => {
|
|
63
|
+
const name = FRAME_NAME_PATTERN.exec(line)?.[1] ?? '';
|
|
64
|
+
const isNative = parseFrameLocation(line) === NATIVE_FRAME_LOCATION;
|
|
65
|
+
return isNative || REACT_FACTORY_FRAME_NAMES.includes(name);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Source key of the frame that created the element, skipping React's factory frames. */
|
|
69
|
+
const findCreatorSource = (stack: string): string | undefined => {
|
|
70
|
+
const lines = getFrameLines(stack).slice(1);
|
|
71
|
+
const index = lines.findIndex(line => !isSkippedFrame(line));
|
|
72
|
+
const location = index === -1 ? undefined : parseFrameLocation(lines[index]);
|
|
73
|
+
return location === undefined ? undefined : getSourceKey(location);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Source key of the code that created an element, read from its debug stack.
|
|
78
|
+
* Cached per stack object since fibers and alternates share it.
|
|
79
|
+
*/
|
|
80
|
+
const getCreatorSource = (debugStack: unknown): string | undefined => {
|
|
81
|
+
const isCacheable = typeof debugStack === 'object' && debugStack !== null;
|
|
82
|
+
if (isCacheable && creatorSourceCache.has(debugStack)) {
|
|
83
|
+
return creatorSourceCache.get(debugStack) ?? undefined;
|
|
84
|
+
}
|
|
85
|
+
const stack = readStack(debugStack);
|
|
86
|
+
const source = stack === undefined ? undefined : findCreatorSource(stack);
|
|
87
|
+
if (isCacheable) creatorSourceCache.set(debugStack, source ?? null);
|
|
88
|
+
return source;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Source key of the bundle running this module (the host), cached after the first read. */
|
|
92
|
+
const getHostSource = (): string => {
|
|
93
|
+
if (hostSource !== undefined) return hostSource;
|
|
94
|
+
const line = (new Error().stack ?? '').split('\n')[1] ?? '';
|
|
95
|
+
const location = parseFrameLocation(line);
|
|
96
|
+
hostSource =
|
|
97
|
+
location === undefined ? LOCAL_SOURCE_KEY : getSourceKey(location);
|
|
98
|
+
return hostSource;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The fiber whose stack decides authorship. React creates the inner fiber of a
|
|
103
|
+
* MemoComponent itself, with no stack, so it inherits its memo parent's.
|
|
104
|
+
*/
|
|
105
|
+
const getAuthorFiber = (fiber: FiberLike): FiberLike =>
|
|
106
|
+
fiber.return?.tag === FIBER_TAG.MemoComponent && fiber.return.child === fiber
|
|
107
|
+
? fiber.return
|
|
108
|
+
: fiber;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* True when the fiber has no dev stack (prod jsx) or its creator frame lives
|
|
112
|
+
* in a bundle other than the host's. Unparseable stacks count as host.
|
|
113
|
+
*/
|
|
114
|
+
const isAppAuthored = (fiber: FiberLike): boolean => {
|
|
115
|
+
if (!fiber._debugStack) return true;
|
|
116
|
+
const source = getCreatorSource(fiber._debugStack);
|
|
117
|
+
return source !== undefined && source !== getHostSource();
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* True when the fiber's first child was created by host code and is owned by
|
|
122
|
+
* this fiber, which marks a host-internal wrapper.
|
|
123
|
+
*/
|
|
124
|
+
const isHostDefined = (fiber: FiberLike): boolean => {
|
|
125
|
+
const child = fiber.child;
|
|
126
|
+
return !!child && !isAppAuthored(child) && child._debugOwner === fiber;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export {
|
|
130
|
+
getAuthorFiber,
|
|
131
|
+
getCreatorSource,
|
|
132
|
+
getHostSource,
|
|
133
|
+
getSourceKey,
|
|
134
|
+
isAppAuthored,
|
|
135
|
+
isHostDefined,
|
|
136
|
+
parseFrameLocation,
|
|
137
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Fragment, isValidElement } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CELL_WRAPPER_SCAN_DEPTH,
|
|
5
|
+
FIBER_TAG,
|
|
6
|
+
SLOT_SCAN_DEPTH,
|
|
7
|
+
} from '../constants';
|
|
8
|
+
import type { FiberLike } from '../types';
|
|
9
|
+
|
|
10
|
+
type PropBag = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
/** True for non-null objects, the only values that can hold props. */
|
|
13
|
+
const isPropBag = (value: unknown): value is PropBag =>
|
|
14
|
+
typeof value === 'object' && value !== null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Adds the props of React elements that the app passed into a lib unit, found
|
|
18
|
+
* among the props and in arrays up to SLOT_SCAN_DEPTH. Never looks inside an element.
|
|
19
|
+
*/
|
|
20
|
+
const collectSlotProps = (
|
|
21
|
+
props: unknown,
|
|
22
|
+
set: Set<unknown>,
|
|
23
|
+
depth: number,
|
|
24
|
+
): void => {
|
|
25
|
+
if (depth > SLOT_SCAN_DEPTH || !isPropBag(props)) return;
|
|
26
|
+
Object.values(props).forEach(value => {
|
|
27
|
+
if (isValidElement(value)) {
|
|
28
|
+
set.add(value.props);
|
|
29
|
+
if (value.type === Fragment)
|
|
30
|
+
collectSlotProps(value.props, set, depth + 1);
|
|
31
|
+
} else if (Array.isArray(value)) {
|
|
32
|
+
collectSlotProps(value, set, depth + 1);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* True for a VirtualizedList cell, detected by its props shape (`cellKey` and a
|
|
39
|
+
* numeric `index`) because release bundles may minify component names.
|
|
40
|
+
*/
|
|
41
|
+
const isListCell = (fiber: FiberLike): boolean => {
|
|
42
|
+
const props = fiber.memoizedProps;
|
|
43
|
+
if (fiber.tag !== FIBER_TAG.ClassComponent || !isPropBag(props)) return false;
|
|
44
|
+
return 'cellKey' in props && typeof props.index === 'number';
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** True for the context provider and fragment fibers that sit above a cell's wrapper view. */
|
|
48
|
+
const isCellPassThrough = (fiber: FiberLike): boolean =>
|
|
49
|
+
fiber.tag === FIBER_TAG.ContextProvider || fiber.tag === FIBER_TAG.Fragment;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Adds the rendered item and separator of a list cell to the unit's slots. They
|
|
53
|
+
* come from `renderItem`, so no app-passed props hold them; they are the
|
|
54
|
+
* children of the wrapper under the cell's providers.
|
|
55
|
+
*/
|
|
56
|
+
const collectCellSlots = (cell: FiberLike, unitSlots: Set<unknown>): void => {
|
|
57
|
+
let wrapper = cell.child;
|
|
58
|
+
for (
|
|
59
|
+
let step = 0;
|
|
60
|
+
wrapper && isCellPassThrough(wrapper) && step < CELL_WRAPPER_SCAN_DEPTH;
|
|
61
|
+
step += 1
|
|
62
|
+
) {
|
|
63
|
+
wrapper = wrapper.child;
|
|
64
|
+
}
|
|
65
|
+
if (wrapper) collectSlotProps(wrapper.memoizedProps, unitSlots, 0);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export { collectCellSlots, collectSlotProps, isListCell };
|