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
1 change: 1 addition & 0 deletions RNRive.podspec
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "json"
require_relative 'ios/rive_textual_glog'

package = JSON.parse(File.read(File.join(__dir__, "package.json")))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ object EventPropertiesOutputExtensions {
EventPropertiesOutput.First(value)

fun string(value: String): EventPropertiesOutput =
EventPropertiesOutput.Second(value)
EventPropertiesOutput.Third(value)

fun number(value: Double): EventPropertiesOutput =
EventPropertiesOutput.Third(value)
EventPropertiesOutput.Second(value)
}
140 changes: 140 additions & 0 deletions example/__tests__/optional-prop-clear.harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Fabric sends `null` (not `undefined`) when a prop is removed. Nitro's
* JSIConverter<std::optional<T>> only maps `undefined` to nullopt, so clearing
* an optional RiveView prop throws during commit — e.g.
* "RiveView.layoutScaleFactor: Value is null, expected a number" —
* https://github.com/mrousavy/nitro/issues/1184.
*
* The throw surfaces as a React error, not through onError, and an error
* boundary above the view would swallow it — hence the boundary here, which
* turns it into an assertable value.
*/
import {
describe,
it,
expect,
render,
waitFor,
cleanup,
} from 'react-native-harness';
import { Component, type ReactNode } from 'react';
import { View } from 'react-native';
import {
RiveView,
RiveFileFactory,
DataBindMode,
Fit,
type RiveFile,
type RiveViewRef,
} from '@rive-app/react-native';

const QUICK_START = require('../assets/rive/quick_start.riv');

type Ctx = {
ref: RiveViewRef | null;
error: string | null;
thrown: string | null;
};

function createCtx(): Ctx {
return { ref: null, error: null, thrown: null };
}

class CatchCommitError extends Component<
{ ctx: Ctx; children: ReactNode },
{ failed: boolean }
> {
state = { failed: false };

static getDerivedStateFromError() {
return { failed: true };
}

componentDidCatch(error: Error) {
this.props.ctx.thrown = error.message;
}

render() {
return this.state.failed ? null : this.props.children;
}
}

function Probe({
file,
ctx,
dataBind,
layoutScaleFactor,
}: {
file: RiveFile;
ctx: Ctx;
dataBind?: DataBindMode;
layoutScaleFactor?: number;
}) {
return (
<View style={{ width: 200, height: 200 }}>
<CatchCommitError ctx={ctx}>
<RiveView
hybridRef={{ f: (r: RiveViewRef | null) => (ctx.ref = r) }}
style={{ flex: 1 }}
file={file}
fit={Fit.Contain}
dataBind={dataBind}
layoutScaleFactor={layoutScaleFactor}
onError={(e) => (ctx.error = e.message)}
/>
</CatchCommitError>
</View>
);
}

async function renderAndWait(element: React.ReactElement, ctx: Ctx) {
const result = await render(element);
await waitFor(() => expect(ctx.ref).not.toBeNull(), { timeout: 5000 });
await ctx.ref!.awaitViewReady();
return result;
}

describe('optional prop clear', () => {
it('clearing dataBind back to undefined restores the default mode', async () => {
const file = await RiveFileFactory.fromSource(QUICK_START, undefined);
const ctx = createCtx();

const { rerender } = await renderAndWait(
<Probe file={file} ctx={ctx} dataBind={DataBindMode.None} />,
ctx
);
expect(ctx.ref!.getViewModelInstance()).toBeUndefined();

// Removing the prop makes Fabric send null for it
await rerender(<Probe file={file} ctx={ctx} dataBind={undefined} />);
await new Promise((r) => setTimeout(r, 800));

expect(ctx.thrown).toBeNull();
expect(ctx.error).toBeNull();
// Back to the default Auto mode, which binds the default instance
expect(ctx.ref!.getViewModelInstance()).toBeDefined();

cleanup();
});

it('clearing layoutScaleFactor back to undefined does not throw', async () => {
const file = await RiveFileFactory.fromSource(QUICK_START, undefined);
const ctx = createCtx();

const { rerender } = await renderAndWait(
<Probe file={file} ctx={ctx} layoutScaleFactor={2} />,
ctx
);

// optional<double> rejects the null sentinel outright
await rerender(
<Probe file={file} ctx={ctx} layoutScaleFactor={undefined} />
);
await new Promise((r) => setTimeout(r, 800));

expect(ctx.thrown).toBeNull();
expect(ctx.error).toBeNull();

cleanup();
});
});
145 changes: 145 additions & 0 deletions example/__tests__/view-recreate.harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {
describe,
it,
expect,
render,
waitFor,
cleanup,
} from 'react-native-harness';
import { useState } from 'react';
import { View } from 'react-native';
import {
RiveView,
RiveFileFactory,
Fit,
type RiveFile,
type RiveViewRef,
} from '@rive-app/react-native';
import type { ViewModelInstance } from '@rive-app/react-native';

/**
* Fabric deletes a component view when its subtree stops being mounted
* (`display: 'none'`, or a react-native-screens screen frozen by
* `enableFreeze(true)`) and recreates it from the same, unchanged ShadowNode
* when the subtree comes back. The recreated view has to be configured from
* those props.
*
* On iOS it was not: nitro tracks props with `isDirty` flags stored on the
* shared Props object and clears them once applied, so the second view
* instance was handed a props object whose flags the first instance had
* already consumed. It never received its file, stayed blank forever, and the
* ref JS holds kept pointing at the dead view. See PR #365.
*/

const QUICK_START = require('../assets/rive/quick_start.riv');

function expectDefined<T>(value: T): asserts value is NonNullable<T> {
expect(value).toBeDefined();
}

type TestContext = {
ref: RiveViewRef | null;
error: string | null;
setHidden: ((hidden: boolean) => void) | null;
};

// The visibility state lives here so that flipping it re-renders this
// component only, leaving the RiveView's own ShadowNode untouched — the
// situation a frozen screen creates.
function HideableRive({
context,
file,
instance,
}: {
context: TestContext;
file: RiveFile;
instance: ViewModelInstance;
}) {
const [hidden, setHidden] = useState(false);
context.setHidden = setHidden;

return (
<View
style={{ width: 200, height: 200, display: hidden ? 'none' : 'flex' }}
>
<RiveView
hybridRef={{
f: (ref: RiveViewRef | null) => {
context.ref = ref;
},
}}
style={{ flex: 1 }}
file={file}
autoPlay={true}
dataBind={instance}
fit={Fit.Contain}
stateMachineName="State Machine 1"
onError={(e) => {
context.error = e.message;
}}
/>
</View>
);
}

// A trigger only reaches its listener while a live view advances the state
// machine the instance is bound to, which is what makes this a usable answer
// to "is the view still driving this data binding?".
async function triggerReachesListener(
instance: ViewModelInstance
): Promise<boolean> {
const trigger = instance.triggerProperty('gameOver');
expectDefined(trigger);
let fired = false;
const removeListener = trigger.addListener(() => {
fired = true;
});
trigger.trigger();
await waitFor(
() => {
expect(fired).toBe(true);
},
{ timeout: 1000 }
).catch(() => {});
removeListener();
trigger.dispose();
return fired;
}

describe('view recreated by Fabric (PR #365)', () => {
it('keeps driving its data binding after hide/show', async () => {
const file = await RiveFileFactory.fromSource(QUICK_START, undefined);
const vm = file.defaultArtboardViewModel();
expectDefined(vm);
const instance = vm.createDefaultInstance();
expectDefined(instance);

const context: TestContext = { ref: null, error: null, setHidden: null };

await render(
<HideableRive context={context} file={file} instance={instance} />
);

await waitFor(
() => {
expect(context.ref).not.toBeNull();
},
{ timeout: 5000 }
);
await context.ref!.awaitViewReady();

// Control: the trigger reaches its listener while the view is alive, so a
// failure below means the view stopped working, not that the probe never did.
expect(await triggerReachesListener(instance)).toBe(true);

context.setHidden!(true);
await new Promise((r) => setTimeout(r, 400));
context.setHidden!(false);
await new Promise((r) => setTimeout(r, 600));

expect(context.error).toBeNull();
expect(await triggerReachesListener(instance)).toBe(true);

cleanup();
});
});
2 changes: 1 addition & 1 deletion example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"react": "19.1.0",
"react-native": "0.80.3",
"react-native-gesture-handler": "2.29.1",
"react-native-nitro-modules": "0.35.10",
"react-native-nitro-modules": "0.37.1",
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "^5.4.0",
"react-native-screens": "~4.18.0",
Expand Down
2 changes: 1 addition & 1 deletion expo-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "2.29.1",
"react-native-nitro-modules": "0.35.10",
"react-native-nitro-modules": "0.37.1",
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
Expand Down
2 changes: 1 addition & 1 deletion expo55-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"react-dom": "19.2.0",
"react-native": "0.83.4",
"react-native-gesture-handler": "~2.29.0",
"react-native-nitro-modules": "0.35.10",
"react-native-nitro-modules": "0.37.1",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
Expand Down
2 changes: 1 addition & 1 deletion expo57-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"react-dom": "19.2.3",
"react-native": "0.86.0",
"react-native-gesture-handler": "~2.32.0",
"react-native-nitro-modules": "0.35.10",
"react-native-nitro-modules": "0.37.1",
"react-native-reanimated": "4.5.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
Expand Down
4 changes: 2 additions & 2 deletions ios/EventPropertiesOutputExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ extension EventPropertiesOutput {
}

static func string(_ value: String) -> EventPropertiesOutput {
return .second(value)
return .third(value)
}

static func number(_ value: Double) -> EventPropertiesOutput {
return .third(value)
return .second(value)
}
}
33 changes: 33 additions & 0 deletions ios/rive_textual_glog.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# NitroModules 0.37.x exposes `ReactProp.hpp` in its modulemap, which reaches
# `<glog/logging.h>` through React's `RawValue.h`. glog 0.3.5 includes headers
# from inside `namespace google`, which is illegal once glog is imported as a
# module, so every target that builds the NitroModules module fails to compile.
# Same class of breakage as mrousavy/nitro#1520, whose fix (0.37.1) covered
# only the `cxxreact` half. Nothing in React Native `@import`s glog, so drop
# its modulemap and let every target include it textually. Remove once Nitro
# keeps React's renderer headers out of its public modulemap.
def rive_use_textual_glog(installer)
support_files = File.join(installer.sandbox.root, 'Target Support Files')
Dir.glob(File.join(support_files, '*', '*.xcconfig')).each do |xcconfig|
contents = File.read(xcconfig)
patched = contents.gsub(
/\s*(-Xcc\s+)?-fmodule-map-file="\$\{PODS_ROOT\}\/Headers\/Public\/glog\/glog\.modulemap"/, ''
)
File.write(xcconfig, patched) if patched != contents
end
end

# The break happens while the NitroModules pod itself compiles, before any of
# this pod's build phases run, and CocoaPods gives a dependency's podspec no
# post-install hook — so hook the installer from here (podspecs are plain Ruby
# evaluated inside every `pod install`). Consumer apps need no Podfile change.
if defined?(Pod::Installer) && !$rive_textual_glog_hooked
$rive_textual_glog_hooked = true
module RiveTextualGlog
def perform_post_install_actions
super
rive_use_textual_glog(self)
end
end
Pod::Installer.prepend(RiveTextualGlog)
end
Loading