Skip to content
Merged
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
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;
});
// Re-fire while waiting: a probe can land before the async auto-bind or the
// remount has finished on a slow emulator, and a fresh trigger costs
// nothing. A dead view never dispatches no matter how often it's fired.
for (let i = 0; i < 30 && !fired; i++) {
trigger.trigger();
await new Promise((r) => setTimeout(r, 500));
}
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();
});
});
36 changes: 24 additions & 12 deletions nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

101 changes: 94 additions & 7 deletions scripts/nitrogen-postprocess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const COMPONENT_FILE = join(
ROOT,
'nitrogen/generated/shared/c++/views/HybridRiveViewComponent.cpp'
);
const IOS_COMPONENT_FILE = join(
ROOT,
'nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm'
);

function makeHybridRiveViewManagerOpen() {
if (!existsSync(MANAGER_FILE)) {
Expand Down Expand Up @@ -44,6 +48,10 @@ function acceptNullForOptionalProps() {
}

const content = readFileSync(COMPONENT_FILE, 'utf-8');
if (content.includes('value.isNull()')) {
console.log('HybridRiveViewComponent.cpp already accepts null props');
return;
}
const pattern =
/^( *)return (CachedProp<std::optional<.+>>)::fromRawValue\(\*runtime, value, (sourceProps\.\w+)\);$/gm;
const updated = content.replace(
Expand All @@ -53,13 +61,9 @@ function acceptNullForOptionalProps() {
);

if (content === updated) {
if (content.includes('value.isNull()')) {
console.log('HybridRiveViewComponent.cpp already accepts null props');
} else {
console.warn(
'No optional CachedProp parse sites found in HybridRiveViewComponent.cpp — nitrogen output may have changed shape'
);
}
console.warn(
'No optional CachedProp parse sites found in HybridRiveViewComponent.cpp — nitrogen output may have changed shape'
);
return;
}

Expand All @@ -69,5 +73,88 @@ function acceptNullForOptionalProps() {
);
}

// Fabric can recreate a component view from an unchanged ShadowNode (e.g.
// react-freeze / Suspense re-inserting a previously hidden screen, or a plain
// display:none toggle). Nitro 0.35's isDirty prop flags live on the shared
// Props object and were already consumed by the previous view instance, so the
// recreated view's updateProps applies nothing: no file, no artboard, and a
// hybridRef that never fires (issue #365). Force-apply every prop on a view
// instance's first updateProps. Fixed upstream in nitro 0.37 (the generated
// code diffs old vs new props instead) — drop this when bumping past 0.36.
function forceApplyPropsOnFreshComponentView() {
if (!existsSync(IOS_COMPONENT_FILE)) {
console.warn('HybridRiveViewComponent.mm not found, skipping');
return;
}

const content = readFileSync(IOS_COMPONENT_FILE, 'utf-8');
if (content.includes('_didApplyInitialProps')) {
console.log(
'HybridRiveViewComponent.mm already force-applies initial props'
);
return;
}

const ivarAnchor = `@implementation HybridRiveViewComponent {
std::shared_ptr<HybridRiveViewSpecSwift> _hybridView;
}`;
const updatePropsAnchor = ` auto& newViewProps = const_cast<HybridRiveViewProps&>(newViewPropsConst);
RNRive::HybridRiveViewSpec_cxx& swiftPart = _hybridView->getSwiftPart();
`;
const recycleAnchor = `- (void)prepareForRecycle {
[super prepareForRecycle];`;
const dirtyCheck = /if \((newViewProps\.\w+\.isDirty)\) \{/g;

if (
!content.includes(ivarAnchor) ||
!content.includes(updatePropsAnchor) ||
!content.includes(recycleAnchor) ||
!dirtyCheck.test(content)
) {
console.warn(
'HybridRiveViewComponent.mm anchors not found — nitrogen output may have changed shape'
);
return;
}
dirtyCheck.lastIndex = 0;

const updated = content
.replace(
ivarAnchor,
`@implementation HybridRiveViewComponent {
std::shared_ptr<HybridRiveViewSpecSwift> _hybridView;
// The cached props' isDirty flags were already consumed by the previous
// view instance when Fabric recreates this view from an unchanged
// ShadowNode, so updateProps would apply nothing and the fresh view would
// stay unconfigured (issue #365). Track whether this instance applied its
// props at least once.
BOOL _didApplyInitialProps;
}`
)
.replace(
updatePropsAnchor,
updatePropsAnchor +
`
// Force-apply all props the first time this view instance updates (see
// _didApplyInitialProps above).
const bool force = !_didApplyInitialProps;
_didApplyInitialProps = YES;
`
)
.replace(dirtyCheck, 'if (force || $1) {')
.replace(
recycleAnchor,
recycleAnchor +
`
_didApplyInitialProps = NO;`
);

writeFileSync(IOS_COMPONENT_FILE, updated);
console.log(
'Patched HybridRiveViewComponent.mm to force-apply props on a fresh view'
);
}

makeHybridRiveViewManagerOpen();
acceptNullForOptionalProps();
forceApplyPropsOnFreshComponentView();
Loading