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
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();
});
});
15 changes: 15 additions & 0 deletions example/ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,20 @@ target 'RiveExample' do
# :ccache_enabled => true
)

# NitroModules 0.37.0 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, which fixed only the `cxxreact` half of it in 0.37.0.
# 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.
Dir.glob(File.join(__dir__, 'Pods', 'Target 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
end
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.0",
"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.0",
"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.0",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
Expand Down
1 change: 1 addition & 0 deletions expo57-example/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ module.exports = {
fonts: ['./assets/kanit_regular.ttf'],
},
],
'./plugins/with-textual-glog',
],
experiments: {
typedRoutes: true,
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.0",
"react-native-reanimated": "4.5.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
Expand Down
Loading