Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { transformSync } from '@babel/core';
import * as babelTypes from '@babel/types';
import glob from 'fast-glob';
import fs from 'fs';
import path from 'path';
Expand Down Expand Up @@ -371,6 +372,12 @@ function generateSessionReplayAssets() {

const reactNativeSVG = new ReactNativeSVG(rootDir, assetsPath, true);

// pre() skips its own setApiTypes/buildSvgMap call when __internal_reactNativeSVG
// is injected, so do it here up front instead — buildSvgMap() is a no-op until
// setApiTypes() has run.
reactNativeSVG.setApiTypes(babelTypes);
reactNativeSVG.buildSvgMap();

for (const file of files) {
try {
const code = fs.readFileSync(file, 'utf8');
Expand Down
8 changes: 7 additions & 1 deletion packages/react-native-babel-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,19 @@ export default declare(
assetsPath = getAssetsPath();
}

reactNativeSVG = options.__internal_reactNativeSVG;
// Reuse the instance across files in the same worker instead of
// rebuilding it (and rescanning) per file.
reactNativeSVG =
options.__internal_reactNativeSVG ?? reactNativeSVG;

if (!reactNativeSVG && assetsPath) {
reactNativeSVG = new ReactNativeSVG(
process.cwd(),
assetsPath,
options.__internal_saveSvgMapToDisk || false
);
// buildSvgMap() is a no-op until setApiTypes() has run.
reactNativeSVG.setApiTypes(api.types);
reactNativeSVG.buildSvgMap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope SVG aliases to the current file

With buildSvgMap() now actually running before transforms, the plugin enables a project-wide localSvgMap keyed only by JSX tag name, while HandlerResolver.create() only checks localSvgMap[name] and does not verify that the current file imported that tag from the mapped SVG. In a project where any scanned file imports or re-exports Icon/Logo from an SVG, every <Icon />/<Logo /> in transformed files can be wrapped with that SVG asset, including normal React components or same-named SVG imports from other files, so Session Replay can record the wrong element/resource. The map needs to be scoped by source file/import binding (or resolved from the current file) before this global build is reused.

Useful? React with 👍 / 👎.

}
reactNativeSVG?.setApiTypes(api.types);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,15 @@ export class ReactNativeSVG {
);
for (const spec of path.node.specifiers) {
if (spec.type === 'ExportSpecifier') {
// spec.local.name is 'default' for `export { default as Logo }`;
// spec.exported is the name consumers actually import ('Logo').
// It can be an Identifier or a string literal, so handle both.
const exported = spec.exported;
const name = getNodeName(
this.t,
spec.local.name
this.t.isStringLiteral(exported)
? exported.value
: exported.name
);
if (name) {
this.localSvgMap[name] = {
Expand Down
104 changes: 104 additions & 0 deletions packages/react-native-babel-plugin/test/react-native-svg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -827,3 +827,107 @@ describe('SessionReplayView.Privacy SVG Wrapper', () => {
expect(output).not.toContain('style');
});
});

describe('ReactNativeSVG.buildSvgMap', () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-buildsvgmap-'));
// Write a minimal SVG file for imports to point at
fs.writeFileSync(
path.join(tmpDir, 'icon.svg'),
'<svg xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="40"/></svg>'
);
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

// buildSvgMap requires setApiTypes to be called first (this.t guard).
it('should populate localSvgMap from a default import of an SVG file', () => {
const srcFile = path.join(tmpDir, 'Component.tsx');
fs.writeFileSync(
srcFile,
`import Logo from './icon.svg';\nexport default function C() { return <Logo />; }`
);

const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
instance.setApiTypes(t); // must come before buildSvgMap
instance.buildSvgMap();

expect(instance.localSvgMap['Logo']).toBeDefined();
expect(instance.localSvgMap['Logo'].path).toBe(
path.join(tmpDir, 'icon.svg')
);
});

it('should populate localSvgMap from a named import of an SVG file', () => {
const srcFile = path.join(tmpDir, 'Component.tsx');
fs.writeFileSync(
srcFile,
`import { ReactComponent as StarIcon } from './icon.svg';\nexport default function C() { return <StarIcon />; }`
);

const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
instance.setApiTypes(t);
instance.buildSvgMap();

expect(instance.localSvgMap['StarIcon']).toBeDefined();
});

// spec.local.name is 'default' for `export { default as Logo }` — the exported
// name ('Logo') must be used as the map key instead.
it('should populate localSvgMap with the exported name for aliased re-exports', () => {
const barrelFile = path.join(tmpDir, 'icons.ts');
fs.writeFileSync(
barrelFile,
`export { default as Logo } from './icon.svg';`
);

const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
instance.setApiTypes(t);
instance.buildSvgMap();

// 'Logo' is what consumers import and use as <Logo/> — must be the key
expect(instance.localSvgMap['Logo']).toBeDefined();
expect(instance.localSvgMap['Logo'].path).toBe(
path.join(tmpDir, 'icon.svg')
);
// 'default' must NOT be stored — it matches nothing in user JSX
expect(instance.localSvgMap['default']).toBeUndefined();
});

it('should populate localSvgMap with the exported name for non-aliased re-exports', () => {
const barrelFile = path.join(tmpDir, 'icons.ts');
fs.writeFileSync(barrelFile, `export { StarIcon } from './icon.svg';`);

const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
instance.setApiTypes(t);
instance.buildSvgMap();

expect(instance.localSvgMap['StarIcon']).toBeDefined();
});

// pre() must reuse the shared instance across files rather than rebuilding it
// (and rescanning) per file.
it('should not overwrite localSvgMap when buildSvgMap is called a second time on a fresh instance with saveSvgMapToDisk=false and no cache file', () => {
const srcFile = path.join(tmpDir, 'Component.tsx');
fs.writeFileSync(srcFile, `import Logo from './icon.svg';`);

const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
instance.setApiTypes(t);
instance.buildSvgMap(); // first call — populates from scan

const mapAfterFirstCall = { ...instance.localSvgMap };

// Simulate what the old code did: create a brand-new instance per file
const freshInstance = new ReactNativeSVG(tmpDir, tmpDir, false);
freshInstance.setApiTypes(t);
freshInstance.buildSvgMap(); // should re-populate identically

// Both instances should have the same map — the ?? fix ensures the first
// instance is reused rather than a fresh empty one being created
expect(freshInstance.localSvgMap).toEqual(mapAfterFirstCall);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,25 @@ internal open class SvgViewMapper<T: ViewGroup>(
val wireframes = mutableListOf<MobileSegment.Wireframe>()

if (view is DdPrivacyView) {
val hash = view.attributes?.get("hash") ?: return listOf(
MobileSegment.Wireframe.ShapeWireframe(
resolveViewId(view),
viewGlobalBounds.x,
viewGlobalBounds.y,
viewGlobalBounds.width,
viewGlobalBounds.height,
shapeStyle = shapeStyle,
border = border
)
// Built once and added up front (before the data guards below) so the view
// still renders as an outlined placeholder if SVG data is unavailable,
// instead of being completely invisible in the replay.
val containerWireframe = MobileSegment.Wireframe.ShapeWireframe(
resolveViewId(view),
viewGlobalBounds.x,
viewGlobalBounds.y,
viewGlobalBounds.width,
viewGlobalBounds.height,
shapeStyle = shapeStyle,
border = border
)

val hash = view.attributes?.get("hash") ?: return listOf(containerWireframe)
val width = view.attributes?.get("width")
val height = view.attributes?.get("height")

wireframes.add(containerWireframe)

var entryData = internalCallback.getEntryData(hash)
?: return wireframes

Expand All @@ -96,16 +101,6 @@ internal open class SvgViewMapper<T: ViewGroup>(
entryData = entryStr.toByteArray(Charsets.UTF_8);
}

wireframes.add(MobileSegment.Wireframe.ShapeWireframe(
resolveViewId(view),
viewGlobalBounds.x,
viewGlobalBounds.y,
viewGlobalBounds.width,
viewGlobalBounds.height,
shapeStyle = shapeStyle,
border = border
))

val imageWireframeId = viewIdentifierResolver.resolveChildUniqueIdentifier(view, "svg") ?: return wireframes
val imgWireframe = MobileSegment.Wireframe.ImageWireframe(
imageWireframeId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ internal class SvgViewMapperTest {
}

@Test
fun `M return empty list W map() { DdPrivacyView with hash but no entry data }`() {
fun `M return ShapeWireframe W map() { DdPrivacyView with hash but no entry data }`() {
// A missing assets.bin or unpacked hash should still render the view's
// bounding box rather than making the element invisible in the replay.
// Given
val hash = "missing-entry-hash"
whenever(mockDdPrivacyView.attributes).thenReturn(mapOf("hash" to hash))
Expand All @@ -163,11 +165,14 @@ internal class SvgViewMapperTest {
)

// Then
assertThat(result).isEmpty()
assertThat(result).hasSize(1)
assertThat(result[0]).isInstanceOf(MobileSegment.Wireframe.ShapeWireframe::class.java)
}

@Test
fun `M return empty list W map() { DdPrivacyView with hash but no child view }`() {
fun `M return ShapeWireframe W map() { DdPrivacyView with hash but no child view }`() {
// Same fallback: if the Babel-generated child view is missing (e.g. collapsed
// by the Fabric renderer), we still show the container outline.
// Given
val hash = "no-child-hash"
val svgBytes = "<svg></svg>".toByteArray(Charsets.UTF_8)
Expand All @@ -184,6 +189,7 @@ internal class SvgViewMapperTest {
)

// Then
assertThat(result).isEmpty()
assertThat(result).hasSize(1)
assertThat(result[0]).isInstanceOf(MobileSegment.Wireframe.ShapeWireframe::class.java)
}
}