Skip to content
Open
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
2 changes: 1 addition & 1 deletion .flowconfig
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ module.name_mapper='^react-native/src/fb_internal/\(.*\)$' -> '<PROJECT_ROOT>/pa
# `src/fb_internal/*` stay importable.
module.name_mapper='^react-native/\(Libraries\|src/private\|src/types\)/.*$' -> 'react-native-deep-import-is-not-part-of-the-public-api'
module.name_mapper='^@react-native/dev-middleware$' -> '<PROJECT_ROOT>/packages/dev-middleware'
module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\|xml\|ktx\|heic\|heif\)$' -> '<PROJECT_ROOT>/packages/react-native/Libraries/Image/RelativeImageStub'
module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\|xml\|ktx\|heic\|ttf\|heif\)$' -> '<PROJECT_ROOT>/packages/react-native/Libraries/Image/RelativeImageStub'

module.system.haste.module_ref_prefix=m#

Expand Down
17 changes: 17 additions & 0 deletions flow-typed/npm/pngjs_v7.x.x.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* @flow strict
* @format
*/

declare module 'pngjs/lib/png' {
declare export class PNG {
constructor(options: {width: number, height: number}): void;
width: number;
height: number;
data: Buffer;
static sync: {
read(buffer: Buffer): PNG,
write(image: PNG): Buffer,
};
}
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"@babel/preset-env": "^7.25.3",
"@babel/preset-flow": "^7.24.7",
"@electron/packager": "^20.0.0",
"@expo-google-fonts/noto-color-emoji": "0.4.7",
"@expo/spawn-async": "^1.7.2",
"@jest/create-cache-key-function": "^29.7.0",
"@jest/test-sequencer": "^29.7.0",
Expand All @@ -82,6 +83,7 @@
"connect": "^3.6.5",
"debug": "^4.4.0",
"deep-equal": "1.1.1",
"dejavu-fonts-ttf": "2.37.3",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-babel": "^5.3.1",
Expand Down
4 changes: 2 additions & 2 deletions packages/new-app-screen/src/NewAppScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import Links from './Links';
import RuntimeVersion from './RuntimeVersion';
import {ThemedText, useTheme} from './Theme';
import * as React from 'react';
import {
Expand Down Expand Up @@ -135,8 +136,7 @@ function getHermesLabel(): React.Node {

return (
<ThemedText color="secondary" style={styles.label}>
JS Engine: Hermes (
{global.HermesInternal.getRuntimeProperties?.()['OSS Release Version']})
JS Engine: Hermes ({RuntimeVersion.hermes})
</ThemedText>
);
}
Expand Down
16 changes: 16 additions & 0 deletions packages/new-app-screen/src/RuntimeVersion.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

const RuntimeVersion: {hermes: string | void} = {
hermes:
global.HermesInternal?.getRuntimeProperties?.()?.['OSS Release Version'],
};

export default RuntimeVersion;
4 changes: 2 additions & 2 deletions packages/react-native/Libraries/Image/ImageTypes.flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type {HostInstance} from '../..';
import type {RootTag} from '../Types/RootTagTypes';
import type {ResolvedAssetSource} from './AssetSourceResolver';
import type {ImageProps as ImagePropsType} from './ImageProps';
import type {ImageSource} from './ImageSource';
import typeof resolveAssetSource from './resolveAssetSource';

import * as React from 'react';

Expand Down Expand Up @@ -59,7 +59,7 @@ type ImageComponentStaticsIOS = Readonly<{
/**
* @see https://reactnative.dev/docs/image#resolveassetsource
*/
resolveAssetSource(source: ImageSource): ?ImageResolvedAssetSource,
resolveAssetSource: resolveAssetSource,
}>;

type ImageComponentStaticsAndroid = Readonly<{
Expand Down
20 changes: 13 additions & 7 deletions packages/react-native/ReactNativeApi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<eda44383e13efcfb4f814115c4b44547>>
* @generated SignedSource<<fcf142a69b68104edc8bfe851de32d6f>>
*
* This file was generated by scripts/js-api/build-types/index.js.
*/
Expand Down Expand Up @@ -75,6 +75,7 @@ declare const $$index: {
}
declare const $$NativeDeviceInfo: typeof NativeModule_default
declare const $$ReactFabric: typeof ReactFabric_default
declare const $$resolveAssetSource: typeof resolveAssetSource_default
declare const $$ScrollViewContext: typeof ScrollViewContext_default
declare const absoluteFill: AbsoluteFillStyle
declare const AccessibilityInfo: typeof AccessibilityInfo_default
Expand Down Expand Up @@ -917,6 +918,7 @@ declare type $$flattenStyle = typeof $$flattenStyle
declare type $$index = typeof $$index
declare type $$NativeDeviceInfo = typeof $$NativeDeviceInfo
declare type $$ReactFabric = typeof $$ReactFabric
declare type $$resolveAssetSource = typeof $$resolveAssetSource
declare type $$ScrollViewContext = typeof $$ScrollViewContext
declare type absoluteFill = typeof absoluteFill
declare type AbsoluteFillStyle = {
Expand Down Expand Up @@ -2437,6 +2439,7 @@ declare type ImageComponentStaticsAndroid = Readonly<
}
>
declare type ImageComponentStaticsIOS = {
readonly resolveAssetSource: resolveAssetSource
getSize(uri: string): Promise<ImageSize>
getSize(
uri: string,
Expand Down Expand Up @@ -2466,7 +2469,6 @@ declare type ImageComponentStaticsIOS = {
queryCache(urls: Array<string>): Promise<{
[url: string]: "disk" | "disk/memory" | "memory"
}>
resolveAssetSource(source: ImageSource): ImageResolvedAssetSource | undefined
}
declare type ImageErrorEvent = NativeSyntheticEvent<
Readonly<ImageErrorEventData>
Expand Down Expand Up @@ -4116,6 +4118,10 @@ declare type RequiredVirtualizedSectionListProps<
sections: ReadonlyArray<SectionListData<ItemT, SectionT>>
}
declare type requireNativeComponent = typeof requireNativeComponent
declare type resolveAssetSource = typeof $$resolveAssetSource
declare function resolveAssetSource_default(
source: ImageSource | null | undefined,
): null | ResolvedAssetSource | undefined
declare type ResolvedAssetSource = {
readonly height: number | undefined
readonly scale: number
Expand Down Expand Up @@ -5791,7 +5797,7 @@ export {
AlertOptions, // 8a116d2a
AlertType, // 5ab91217
AndroidKeyboardEvent, // e03becc8
Animated, // 33fe7464
Animated, // adba9272
AppConfig, // 35c0ca70
AppRegistry, // edec2a30
AppState, // 12012be5
Expand Down Expand Up @@ -5856,10 +5862,10 @@ export {
IEventEmitter, // fbef6131
IOSKeyboardEvent, // e67bfe3a
IgnorePattern, // ec6f6ece
Image, // a24a5777
ImageBackground, // 1fbbacef
ImageBackgroundInstance, // 9c900a60
ImageBackgroundProps, // b078f0ac
Image, // 404e5c9b
ImageBackground, // d2afc8de
ImageBackgroundInstance, // 2e9ed91b
ImageBackgroundProps, // d49fc98c
ImageErrorEvent, // b6227714
ImageInstance, // df709524
ImageLoadEvent, // 31709c38
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
RootTag,
TurboModule,
} from '../../../../../Libraries/TurboModule/RCTExport';
import type {UnsafeObject} from '../../../../../Libraries/Types/CodegenTypes';

import * as TurboModuleRegistry from '../../../../../Libraries/TurboModule/TurboModuleRegistry';

Expand Down Expand Up @@ -116,6 +117,9 @@ interface Spec extends TurboModule {
surfaceId: RootTag,
config: RenderFormatOptions,
) => string;
takeScreenshot(surfaceId: RootTag): UnsafeObject;
registerAsset(uri: string, filePath: string): void;
registerFont(filePath: string): void;
reportTestSuiteResultsJSON: (results: string) => void;
createShadowNodeReferenceCounter(
shadowNode: unknown /* ShadowNode */,
Expand Down
48 changes: 48 additions & 0 deletions private/react-native-fantom/__docs__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ values. If another root is still alive and the new metrics differ from the
current global dimensions, Fantom applies the new values and emits a warning.
Call `root.destroy()` when a root is no longer needed.

Each root also uses its `fontScale` for native text and text-input measurement,
including line heights and wrapping. `allowFontScaling={false}` opts a component
out, and `maxFontSizeMultiplier` caps its scale. Existing roots retain their own
native font scale when a new root changes the global display metrics.

For a full API reference, please see the [inline documentation](../src/index.js)
defined for the methods in the `@react-native/fantom` [module](../src/index.js).

Expand All @@ -119,6 +124,49 @@ Similar to Jest, you can also run Fantom in watch mode using `--watch`:
yarn fantom <regexForTestFiles> --watch
```

### PNG screenshots

`root.takeScreenshot()` captures the currently mounted Fabric revision into an
immutable `Screenshot`. Capture after `runTask` to include its mounted updates;
capture itself does not flush pending React work. The bitmap remains usable
after later updates or root destruction.

```javascript
const root = Fantom.createRoot();
Fantom.runTask(() => root.render(<Screen />));
const screenshot = root.takeScreenshot();
expect(screenshot).toMatchSnapshot();
expect(screenshot.isEqual(root.takeScreenshot())).toBe(true);
```

The headless renderer paints Yoga's mounted frames with CPU Skia. Text uses the
same layout and bundled fonts as intrinsic measurement, so the measured sizes
and painted content agree. `isEqual()` compares bitmap dimensions and pixels in
memory.

`toMatchSnapshot()` compares a SHA-256 hash of the bitmap dimensions and pixels
with the value in Jest's `.snap` file. The expected PNG is not read for
comparison. The runner also keeps an inspectable image in
`__snapshots__/<test-file>.screenshots/`, named from the test, label and
assertion number. Capture defers PNG encoding until an image must be saved;
encoded bytes are cached for repeated saves. A matching hash passes even if the
PNG is missing, and the runner regenerates that image.

`toMatchInlineSnapshot()` embeds the screenshot's type and hash directly in the
test source. It does not encode or save an image, so inline failures report a
text diff.

New snapshots are saved during a normal local run. `--updateSnapshot` (`-u`)
accepts changed hashes and PNGs together; CI requires an existing matching hash.
A mismatch leaves the baseline unchanged, saves a difference image alongside the
received image, and reports all three image paths for inspection. Unused images
are removed only during a successful snapshot update with no skipped or filtered
tests. Negated snapshot assertions are unsupported.

See [Rendering](rendering.md) for the painting architecture, supported features,
font selection and the configuration that keeps shared baselines consistent
across Linux and macOS.

### Interactive REPL

> [!WARNING]
Expand Down
145 changes: 145 additions & 0 deletions private/react-native-fantom/__docs__/rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Rendering

## Text measurement

Fantom always measures intrinsic `Text` and `TextInput` sizes with Skia's CPU
font metrics. Layout and screenshot painting share the same text layout code,
including fragment styles, constrained-width wrapping, inline attachments, input
values and placeholders. `maximumNumberOfLines` limits the measured box; clipped
attachments retain their entries in Fabric's attachment mapping.

A root's `fontScale` defaults to `1` and is passed to native layout. Text and
inputs respect `allowFontScaling` and `maxFontSizeMultiplier`; line heights
scale with the font size. `devicePixelRatio` determines raster resolution,
rather than the logical text size.

### Fonts and assets

Fantom registers DejaVu Sans regular, bold, italic and bold italic faces from
[`dejavu-fonts-ttf`](https://www.npmjs.com/package/dejavu-fonts-ttf), plus
[`@expo-google-fonts/noto-color-emoji`](https://www.npmjs.com/package/@expo-google-fonts/noto-color-emoji).
Both are pinned Fantom dependencies installed by the repository's normal Yarn
setup. Their font licenses are compatible with React Native distribution; the
packages retain the Bitstream Vera/DejaVu and SIL Open Font License notices.
Registration runs once during Fantom's default environment setup. Tests that
configure React Native directly call `setUpFantomEnvironment()` themselves. No
host font installation or Expo runtime is required.

Metro preserves the source files of bundled assets in Fantom's asset metadata.
The shared resolver selects the asset variant for the root's pixel ratio and
registers its native file path. Ordinary `require()` sources therefore work
without custom path mappings. Keep a fixture alongside the test that uses it.

Default color emoji selection follows Unicode 16.0 `Emoji_Presentation`.
Explicit text and emoji variation selectors override that default. Selectors and
keycap sequences stay with their bases during fallback and wrapping; HarfBuzz
shapes color emoji sequences for both measurement and painting. General script
shaping, text decorations and ellipsis are not implemented.

### Public native build

`yarn workspace @react-native/fantom build` uses Gradle to prepare pinned Skia,
FreeType, libpng, zlib, HarfBuzz and BoringSSL sources. CMake builds HarfBuzz
and BoringSSL as static libraries, and builds Skia with GN and the Android SDK's
Ninja. BoringSSL provides SHA-256 for screenshot pixel hashes; the existing
Boost dependency has SHA-1 and MD5 but no SHA-256 implementation. GN bootstrap
uses Python 3. The usual JDK, Android SDK, C/C++ compiler and Python build tools
are required; graphics, crypto and font libraries come from these prepared
sources or bundled assets.

The native Gradle task tracks C++ sources so edits rebuild the tester.
Dependency versions and download locations are defined in
[`build.gradle.kts`](../build.gradle.kts). Native integration is configured in
[`tester/CMakeLists.txt`](../tester/CMakeLists.txt) and its third-party
subdirectories.

### Consistent raster configuration

Tests share snapshot baselines across Linux and macOS. Different font metrics
can change line wrapping and the placement of other views; different raster
rounding can change pixel hashes even when the images look alike. Keeping both
consistent makes a failing snapshot useful evidence of a rendering change,
independent of the machine running the test.

Both native builds use CPU Skia, FreeType and the same bundled fonts. Host font
managers and platform shaping backends are disabled. Text gamma correction is
configured identically in both builds.

Fantom disables the legacy shader context and lower precision raster pipeline,
forces the raster pipeline blitter, and selects scalar high precision stages.
Floating-point contraction is disabled with `-ffp-contract=off`. This avoids ARM
and x86 optimized stages producing different rounded channel values when
compositing the same pixels, including color emoji. These settings apply only to
Fantom's Skia library.

The pinned Skia source adds optional scalar-stage selection and makes the forced
raster pipeline apply to sprite and saved-layer compositing. This keeps native
pixel-format defaults from selecting byte-based alpha rounding instead. Gradle
patches its prepared source copy, and CMake validates the scalar selector
without modifying the source. Keep the internal target's flags and the public GN
flags aligned when changing Skia.

## Screenshot snapshots

`root.takeScreenshot()` paints the currently mounted revision into an immutable
CPU bitmap. It does not flush pending React work. Captures survive later commits
and root destruction. Use `isEqual()` to compare their dimensions and pixel rows
in memory. `saveToFile()` writes a PNG, creating parent directories and
replacing an existing file.

Use `Fantom.unstable_produceFramesForDuration()` to capture repeatable animation
states without waiting for real time. Native layout animations need
`@fantom_flags useSharedAnimatedBackend:true` in the test and produce
intermediate mounted revisions. Native opacity and transform updates applied
directly to views are visible to screenshots only after they are committed to
the mounted tree.

`toMatchSnapshot()` stores a SHA-256 hash of the image dimensions and
premultiplied RGBA pixel rows. It compares that hash with the ordinary Jest
snapshot value; it does not decode an expected PNG. An inspectable PNG is kept
beside the `.snap` file in `<test-file>.screenshots`. PNG encoding is deferred
until an image must be saved, and PNG bytes are cached for repeated saves. A
matching hash passes even when the standalone PNG is missing; the runner
recreates that image from the matching capture without updating the hash.

`toMatchInlineSnapshot()` embeds the same `{type: 'Screenshot', hash}` value in
the test source. It compares hashes without encoding or saving PNGs, including
when the inline snapshot is created, updated or fails. Inline failures use
ordinary text snapshot diagnostics.

Snapshot mismatches retain the received PNG and save a difference PNG in the
same temporary directory. Failure diagnostics include the paths to the expected,
received and difference images, including in CI and redirected output. The
difference highlights changed pixels in red over faded unchanged content; it
does not affect hash comparison. Missing or unreadable images retain their
original diagnostics without a difference image. Snapshot updates copy it into
the baseline directory before cleaning up the temporary file. Unused images are
removed only during a full successful snapshot update; empty, failed, skipped or
filtered suites preserve their baselines.

### Painting

The painter traverses Fabric's mounted child order, including stacking changes.
Yoga supplies the frames for flow and absolute positioning. Overflow clips
descendants to the view's rounded bounds; each view restores its canvas state
before painting siblings. Nonidentity affine transforms are centered on the
view, while identity transforms preserve child coordinates without extra
floating-point translations. Singular transforms are skipped.

`View`, styled and nested `Text`, `TextInput`, `Image` and `ScrollView` use this
shared canvas. Text inputs paint values or placeholders, including multiline and
secure entry. Scroll views offset their content to show the current viewport.
Backgrounds, solid borders, rounded corners and opacity follow the mounted
props.

Images support local PNG files (`file://` or filesystem paths) and base64 PNG
data URIs, with resize modes and tint. Supply network images as local fixtures.
The shared asset resolver maps bundled `require()` sources to native files while
keeping their React Native URIs stable in text snapshots.

An empty root produces a transparent viewport; a destroyed root cannot be
captured. Viewport offsets do not move the screenshot's origin. The renderer
does not reproduce Android or iOS platform chrome, shadows or perspective
transforms. Additional font fixtures can be registered with
`NativeFantom.registerFont()` before mounting; the default fonts need no
per-test registration.
Loading
Loading