@momo-kits/foundation 0.169.4-beta.8 → 0.169.5-beta.1

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.
@@ -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
+ };
@@ -85,6 +85,17 @@ const COUNTED_FIBER_TAGS: number[] = [
85
85
 
86
86
  const HOST_RN_PREFIXES: string[] = ['RCT', 'Android'];
87
87
 
88
+ const LOCAL_SOURCE_KEY = 'local';
89
+ const REACT_STACK_BOTTOM_FRAME = 'react-stack-bottom-frame';
90
+ const REACT_FACTORY_FRAME_NAMES: string[] = [
91
+ 'jsx',
92
+ 'jsxs',
93
+ 'jsxDEV',
94
+ 'jsxDEVImpl',
95
+ 'createElement',
96
+ 'jsxProd',
97
+ ];
98
+
88
99
  const REACT_MEMO_TYPE = Symbol.for('react.memo');
89
100
  const REACT_FORWARD_REF_TYPE = Symbol.for('react.forward_ref');
90
101
 
@@ -94,9 +105,12 @@ export {
94
105
  FOUNDATION_REEXPORT_NAMES,
95
106
  HOST_RN_PREFIXES,
96
107
  LIB_UNIT_NAMES,
108
+ LOCAL_SOURCE_KEY,
97
109
  MAX_FIBERS,
110
+ REACT_FACTORY_FRAME_NAMES,
98
111
  REACT_FORWARD_REF_TYPE,
99
112
  REACT_MEMO_TYPE,
113
+ REACT_STACK_BOTTOM_FRAME,
100
114
  RN_PRIMITIVE_NAMES,
101
115
  SETTLE_MS,
102
116
  SLOT_SCAN_DEPTH,
@@ -13,6 +13,11 @@ import {
13
13
  SLOT_SCAN_DEPTH,
14
14
  TOP_TRANSPARENT_LIMIT,
15
15
  } from './constants';
16
+ import {
17
+ getAuthorFiber,
18
+ isAppAuthored,
19
+ isHostDefined,
20
+ } from './Authorship/utils';
16
21
  import type {
17
22
  AdoptionBucket,
18
23
  FiberLike,
@@ -156,30 +161,6 @@ const createRegistries = (
156
161
  return { kit, rn };
157
162
  };
158
163
 
159
- /**
160
- * The fiber whose stack decides authorship. React creates the inner fiber of a
161
- * MemoComponent itself, with no stack, so it inherits its memo parent's.
162
- */
163
- const getAuthorFiber = (fiber: FiberLike): FiberLike =>
164
- fiber.return?.tag === FIBER_TAG.MemoComponent && fiber.return.child === fiber
165
- ? fiber.return
166
- : fiber;
167
-
168
- /**
169
- * True when the fiber has no dev stack, i.e. it was created by mini-app code
170
- * (prod bundle) rather than by the host's dev build.
171
- */
172
- const isAppAuthored = (fiber: FiberLike): boolean => !fiber._debugStack;
173
-
174
- /**
175
- * True when the fiber's first child was created by host code and is owned by
176
- * this fiber, which marks a host-internal wrapper.
177
- */
178
- const isHostDefined = (fiber: FiberLike): boolean => {
179
- const child = fiber.child;
180
- return !!child && !isAppAuthored(child) && child._debugOwner === fiber;
181
- };
182
-
183
164
  /** App id from the mini-app context, or a placeholder when it is missing. */
184
165
  const getAppId = (context: unknown): string => {
185
166
  if (!isObjectLike(context) || context.appId == null) return UNKNOWN_APP_ID;
@@ -259,6 +240,7 @@ const topEntries = (
259
240
  .sort((a, b) => b.count - a.count)
260
241
  .slice(0, TOP_TRANSPARENT_LIMIT);
261
242
 
243
+ /** Status 'dev_bundle' means no fiber was authored by a bundle other than the host's. */
262
244
  const getStatus = (
263
245
  truncated: boolean,
264
246
  fibers: number,
@@ -198,10 +198,9 @@ describe('KitAdoption walkScreen', () => {
198
198
  }),
199
199
  [innerView],
200
200
  );
201
- const hostKit = link(
202
- makeFiber({ type: Button, _debugStack: STACK }),
203
- [hostMemo],
204
- );
201
+ const hostKit = link(makeFiber({ type: Button, _debugStack: STACK }), [
202
+ hostMemo,
203
+ ]);
205
204
 
206
205
  const result = walkScreen(
207
206
  hostKit,
@@ -222,14 +221,38 @@ describe('KitAdoption walkScreen', () => {
222
221
  [innerButton],
223
222
  );
224
223
 
225
- const result = walkScreen(
226
- appMemo,
227
- createRegistries([{ Button }], {}),
228
- );
224
+ const result = walkScreen(appMemo, createRegistries([{ Button }], {}));
229
225
 
230
226
  expect(result.counts.K).toBe(1);
231
227
  expect(result.kit.Button).toBe(1);
232
228
  });
229
+ it('counts a kit element created by a mini-app bundle frame but not host-created children', () => {
230
+ const appStack = {
231
+ stack: [
232
+ 'Error: react-stack-top-frame',
233
+ ' at anonymous (http://localhost:8081/index.bundle:1:2)',
234
+ ' at Tab (address at file:///x/Documents/apps/vn.momo.billpay/ios/a.bundle:1:2)',
235
+ ' at react-stack-bottom-frame (http://localhost:8081/index.bundle:1:2)',
236
+ ].join('\n'),
237
+ };
238
+ const hostStack = {
239
+ stack: [
240
+ 'Error: react-stack-top-frame',
241
+ ' at createElement (/repo/node_modules/react/index.js:1:1)',
242
+ ' at Text (/repo/src/foundations/Text/index.tsx:10:5)',
243
+ ' at react-stack-bottom-frame (/repo/node_modules/react/index.js:1:1)',
244
+ ].join('\n'),
245
+ };
246
+ const inner = makeFiber({ type: Plain, _debugStack: hostStack });
247
+ const text = link(makeFiber({ type: Text, _debugStack: appStack }), [
248
+ inner,
249
+ ]);
250
+
251
+ const result = walkScreen(text, createRegistries([{ Text }], {}));
252
+
253
+ expect(result.counts).toEqual({ K: 1, R: 0, O: 0 });
254
+ expect(result.kit.Text).toBe(1);
255
+ });
233
256
  });
234
257
 
235
258
  describe('KitAdoption getCurrentFiber', () => {
@@ -0,0 +1,132 @@
1
+ import {
2
+ getCreatorSource,
3
+ getSourceKey,
4
+ isAppAuthored,
5
+ parseFrameLocation,
6
+ } from '../KitAdoption/Authorship/utils';
7
+ import type { FiberLike } from '../KitAdoption/types';
8
+
9
+ const FACTORY =
10
+ ' at anonymous (http://localhost:8081/index.bundle?platform=ios:221225:88)';
11
+ const BOTTOM =
12
+ ' at react-stack-bottom-frame (http://localhost:8081/index.bundle?platform=ios:195108:33)';
13
+ const APP_DIR = 'file:///Users/x/Library/Documents/apps/vn.momo.billpay/ios';
14
+ const BILLPAY_STACK = [
15
+ 'Error: react-stack-top-frame',
16
+ FACTORY,
17
+ ` at BillPaymentTab (address at ${APP_DIR}/MainGroup.chunk.bundle:1:52311)`,
18
+ BOTTOM,
19
+ ' at renderWithHooks (http://localhost:8081/index.bundle?platform=ios:188891:42)',
20
+ ].join('\n');
21
+ const KIT_STACK = [
22
+ 'Error: react-stack-top-frame',
23
+ ' at anonymous (http://localhost:8081/index.bundle?platform=ios:220604:93)',
24
+ ' at renderTitle (http://localhost:8081/index.bundle?platform=ios:290576:50)',
25
+ ' at Button (http://localhost:8081/index.bundle?platform=ios:290744:32)',
26
+ BOTTOM,
27
+ ].join('\n');
28
+ const HOST_PATH_STACK = [
29
+ 'Error: react-stack-top-frame',
30
+ ' at createElement (/repo/node_modules/react/index.js:1:1)',
31
+ ' at Button (/repo/src/foundations/Button/index.tsx:10:5)',
32
+ BOTTOM,
33
+ ].join('\n');
34
+
35
+ const makeFiber = (debugStack?: unknown): FiberLike => ({
36
+ tag: 0,
37
+ type: undefined,
38
+ _debugStack: debugStack,
39
+ });
40
+
41
+ describe('getSourceKey', () => {
42
+ it('returns the origin for http urls', () => {
43
+ expect(getSourceKey('http://localhost:8081/index.bundle?x=1:2:3')).toBe(
44
+ 'http://localhost:8081',
45
+ );
46
+ });
47
+
48
+ it('returns the directory for file urls', () => {
49
+ expect(getSourceKey('file:///a/b/c.bundle:1:2')).toBe('file:///a/b');
50
+ });
51
+
52
+ it('returns local for bare paths', () => {
53
+ expect(getSourceKey('/repo/src/a.ts:1:2')).toBe('local');
54
+ });
55
+ });
56
+
57
+ describe('parseFrameLocation', () => {
58
+ it('parses the three frame shapes', () => {
59
+ expect(parseFrameLocation(' at fn (http://h:1/a.js:1:2)')).toBe(
60
+ 'http://h:1/a.js:1:2',
61
+ );
62
+ expect(
63
+ parseFrameLocation(' at fn (address at file:///a/b.js:1:2)'),
64
+ ).toBe('file:///a/b.js:1:2');
65
+ expect(parseFrameLocation(' at http://h:1/a.js:1:2')).toBe(
66
+ 'http://h:1/a.js:1:2',
67
+ );
68
+ });
69
+ });
70
+
71
+ describe('getCreatorSource', () => {
72
+ it('returns the mini-app directory for an app-created element', () => {
73
+ expect(getCreatorSource({ stack: BILLPAY_STACK })).toBe(APP_DIR);
74
+ });
75
+
76
+ it('returns the host origin for a kit-created element', () => {
77
+ expect(getCreatorSource({ stack: KIT_STACK })).toBe(
78
+ 'http://localhost:8081',
79
+ );
80
+ });
81
+
82
+ it('skips a jsxDEV wrapper frame', () => {
83
+ const stack = [
84
+ 'Error: react-stack-top-frame',
85
+ FACTORY,
86
+ ' at jsxDEV (http://localhost:8081/index.bundle:1:2)',
87
+ ` at Screen (address at ${APP_DIR}/a.bundle:1:2)`,
88
+ BOTTOM,
89
+ ].join('\n');
90
+ expect(getCreatorSource({ stack })).toBe(APP_DIR);
91
+ });
92
+
93
+ it('skips a native frame', () => {
94
+ const stack = [
95
+ 'Error: react-stack-top-frame',
96
+ FACTORY,
97
+ ' at apply (native)',
98
+ ` at Screen (address at ${APP_DIR}/a.bundle:1:2)`,
99
+ BOTTOM,
100
+ ].join('\n');
101
+ expect(getCreatorSource({ stack })).toBe(APP_DIR);
102
+ });
103
+
104
+ it('returns undefined without a bottom marker', () => {
105
+ const stack = ['Error: x', FACTORY].join('\n');
106
+ expect(getCreatorSource({ stack })).toBeUndefined();
107
+ });
108
+
109
+ it('returns undefined for non-error and string values', () => {
110
+ expect(getCreatorSource('dev-stack')).toBeUndefined();
111
+ expect(getCreatorSource(42)).toBeUndefined();
112
+ expect(getCreatorSource(null)).toBeUndefined();
113
+ });
114
+ });
115
+
116
+ describe('isAppAuthored', () => {
117
+ it('is true without a debug stack', () => {
118
+ expect(isAppAuthored(makeFiber())).toBe(true);
119
+ });
120
+
121
+ it('is true when the creator frame is in another bundle', () => {
122
+ expect(isAppAuthored(makeFiber({ stack: BILLPAY_STACK }))).toBe(true);
123
+ });
124
+
125
+ it('is false when the creator frame is a host source path', () => {
126
+ expect(isAppAuthored(makeFiber({ stack: HOST_PATH_STACK }))).toBe(false);
127
+ });
128
+
129
+ it('is false for an unparseable stack', () => {
130
+ expect(isAppAuthored(makeFiber('dev-stack'))).toBe(false);
131
+ });
132
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.169.4-beta.8",
3
+ "version": "0.169.5-beta.1",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},