Skip to content

Commit 7f5904d

Browse files
mfazekasclaude
andcommitted
feat: make useRiveProperty start undefined and deliver value via listener
All backends now emit the current value as the first listener emission, so hooks no longer need a synchronous property.value read on mount. - useRiveProperty: always starts undefined; value arrives via the listener's first callback (not a sync read). Setter updater fn now uses tracked React state instead of property.value. - iOS legacy: Number/String/Boolean/Enum addListener now emits the current value synchronously on subscribe, matching the stream-based behaviour of the experimental backend (valueStream) and Android (Flow with drop=0). No native changes needed elsewhere. - Tests: mock addListener emits current value on subscribe; updated test description to reflect the new async-first semantics. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 06e7401 commit 7f5904d

File tree

6 files changed

+41
-16
lines changed

6 files changed

+41
-16
lines changed

ios/legacy/HybridViewModelBooleanProperty.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,11 @@ class HybridViewModelBooleanProperty: HybridViewModelBooleanPropertySpec, Valued
1818
property.value = newValue
1919
}
2020
}
21+
22+
func addListener(onChanged: @escaping (Bool) -> Void) throws -> () -> Void {
23+
// Emit current value immediately so the first subscription receives it,
24+
// matching the stream-based behaviour of the experimental backend.
25+
onChanged(value)
26+
return helper.addListener(onChanged)
27+
}
2128
}

ios/legacy/HybridViewModelEnumProperty.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,11 @@ class HybridViewModelEnumProperty: HybridViewModelEnumPropertySpec, ValuedProper
1818
property.value = newValue
1919
}
2020
}
21+
22+
func addListener(onChanged: @escaping (String) -> Void) throws -> () -> Void {
23+
// Emit current value immediately so the first subscription receives it,
24+
// matching the stream-based behaviour of the experimental backend.
25+
onChanged(value)
26+
return helper.addListener(onChanged)
27+
}
2128
}

ios/legacy/HybridViewModelNumberProperty.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ class HybridViewModelNumberProperty: HybridViewModelNumberPropertySpec, ValuedPr
1919
}
2020

2121
func addListener(onChanged: @escaping (Double) -> Void) throws -> () -> Void {
22+
// Emit current value immediately so the first subscription receives it,
23+
// matching the stream-based behaviour of the experimental backend.
24+
onChanged(value)
2225
return helper.addListener({ floatValue in onChanged(Double(floatValue)) })
2326
}
2427
}

ios/legacy/HybridViewModelStringProperty.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,11 @@ class HybridViewModelStringProperty: HybridViewModelStringPropertySpec, ValuedPr
1818
property.value = newValue
1919
}
2020
}
21+
22+
func addListener(onChanged: @escaping (String) -> Void) throws -> () -> Void {
23+
// Emit current value immediately so the first subscription receives it,
24+
// matching the stream-based behaviour of the experimental backend.
25+
onChanged(value)
26+
return helper.addListener(onChanged)
27+
}
2128
}

src/hooks/__tests__/useRiveProperty.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ describe('useRiveProperty', () => {
1515
currentValue = newValue;
1616
listener?.(newValue);
1717
},
18+
// Emit the current value immediately on subscribe, matching native behaviour:
19+
// iOS legacy emits synchronously; experimental backend emits via valueStream.
1820
addListener: jest.fn((callback: (value: string) => void) => {
1921
listener = callback;
22+
callback(currentValue);
2023
return () => {
2124
listener = null;
2225
};
@@ -36,7 +39,9 @@ describe('useRiveProperty', () => {
3639
} as unknown as ViewModelInstance;
3740
};
3841

39-
it('should return initial value from property on first render', () => {
42+
it('should return initial value delivered via listener (not from a sync read)', () => {
43+
// Hooks always start undefined; the listener emits the current value immediately
44+
// on subscribe (synchronously for legacy, via stream for experimental).
4045
const mockProperty = createMockProperty('Tea');
4146
const mockInstance = createMockViewModelInstance({
4247
'favDrink/type': mockProperty,
@@ -48,6 +53,8 @@ describe('useRiveProperty', () => {
4853
})
4954
);
5055

56+
// The mock's addListener emits 'Tea' synchronously — React batches it with the
57+
// effect, so the value is available after renderHook (which wraps in act()).
5158
const [value] = result.current;
5259
expect(value).toBe('Tea');
5360
});

src/hooks/useRiveProperty.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ export function useRiveProperty<P extends ViewModelProperty, T>(
3434
Error | null,
3535
P | undefined,
3636
] {
37-
// Get the property first so we can read its initial value
3837
const property = useMemo(() => {
3938
if (!viewModelInstance) return;
4039
return options.getProperty(
@@ -43,17 +42,12 @@ export function useRiveProperty<P extends ViewModelProperty, T>(
4342
) as unknown as ObservableViewModelProperty<T>;
4443
}, [options, viewModelInstance, path]);
4544

46-
// Initialize state with property's current value (if available)
47-
const [value, setValue] = useState<T | undefined>(() => property?.value);
45+
// Always start undefined — the listener delivers the current value as its first emission.
46+
// (iOS experimental: via valueStream; iOS/Android legacy: emitted synchronously on subscribe)
47+
// This ensures consumers handle the loading state correctly on all backends.
48+
const [value, setValue] = useState<T | undefined>(undefined);
4849
const [error, setError] = useState<Error | null>(null);
4950

50-
// Sync value when property reference changes (path or instance changed)
51-
useEffect(() => {
52-
if (property) {
53-
setValue(property.value);
54-
}
55-
}, [property]);
56-
5751
// Clear error when path or instance changes
5852
useEffect(() => {
5953
setError(null);
@@ -86,22 +80,22 @@ export function useRiveProperty<P extends ViewModelProperty, T>(
8680
};
8781
}, [options, property]);
8882

89-
// Set the value of the property (no-op if property isn't available yet)
83+
// Set the value of the property (no-op if property isn't available yet).
84+
// Uses tracked `value` from state for updater functions — avoids a synchronous
85+
// property.value read and is consistent with how React state works.
9086
const setPropertyValue = useCallback(
9187
(valueOrUpdater: T | ((prevValue: T | undefined) => T)) => {
9288
if (!property) {
9389
return;
9490
} else {
9591
const newValue =
9692
typeof valueOrUpdater === 'function'
97-
? (valueOrUpdater as (prevValue: T | undefined) => T)(
98-
property.value
99-
)
93+
? (valueOrUpdater as (prevValue: T | undefined) => T)(value)
10094
: valueOrUpdater;
10195
property.value = newValue;
10296
}
10397
},
104-
[property]
98+
[property, value]
10599
);
106100

107101
return [value, setPropertyValue, error, property as unknown as P];

0 commit comments

Comments
 (0)