Skip to content

valueStream throws missingData when string property is set to empty string #416

@mfazekas

Description

@mfazekas

Description

When using the @_spi(RiveExperimental) API, setting a ViewModel string property to an empty string ("") causes valueStream to throw a missingData error. This terminates the AsyncThrowingStream permanently — the listener is killed and cannot receive any further updates.

An empty string is a valid value and should be emitted through the stream, not treated as missing data.

Root cause

The bug originates in the ObjC++ bridge at RiveViewModelInstanceData.mm:90:

case rive::DataType::string:
case rive::DataType::enumType:
    if (!data.stringValue.empty())  // ← filters out empty strings
    {
        _stringValue = [NSString stringWithUTF8String:data.stringValue.c_str()];
    }
    break;

When the C++ runtime delivers an empty string, data.stringValue.empty() returns true, so _stringValue is never set and stays nil.

On the Swift side, ViewModelInstanceService.swift:671-684 checks data.stringValue, which is nil, so all type checks fail and it falls through to:

streamContinuation.finish(throwing: ViewModelInstanceError.missingData)
streamContinuations.removeValue(forKey: requestID)

This kills the stream permanently.

Provide a Repro

@_spi(RiveExperimental) import RiveRuntime

let nameProp = StringProperty(path: "childVm/name")

// Start listening
Task { @MainActor in
    let stream = vmi.valueStream(of: nameProp)
    do {
        for try await val in stream {
            print("Value: '\(val)'")
        }
    } catch {
        // THIS IS HIT: "missingData"
        print("Stream error: \(error)")
    }
}

// Set to empty string — stream throws missingData and terminates
vmi.setValue(of: nameProp, to: "")
Unit tests reproducing the bug
// In ViewModelInstanceTests.swift

@MainActor
func test_valueStream_withStringProperty_throwsMissingDataOnEmptyString() async throws {
    let mockCommandQueue = MockCommandQueue()

    let viewModelInstance = makeViewModelInstance(mockCommandQueue: mockCommandQueue)

    let property = StringProperty(path: "childVm/name")
    let stream = viewModelInstance.valueStream(of: property)

    let subscribeCall = mockCommandQueue.subscribeToViewModelPropertyCalls[0]
    let observer = mockCommandQueue.getObserver(for: 99)
    let requestID = subscribeCall.requestID

    let errorExpectation = expectation(description: "Stream throws missingData")

    let task = Task {
        do {
            for try await _ in stream {}
            XCTFail("Stream should have thrown, not ended normally")
        } catch let error as ViewModelInstanceError {
            guard case .missingData = error else {
                XCTFail("Expected missingData, got \(error)")
                return
            }
            errorExpectation.fulfill()
        }
    }

    // Simulate what happens when the ObjC++ bridge receives an empty string:
    // RiveViewModelInstanceData.mm filters out empty strings via `if (!data.stringValue.empty())`
    // which leaves _stringValue as nil.
    let emptyStringData = MockRiveViewModelInstanceData(stringValue: nil)
    observer?.onViewModelDataReceived(99, requestID: requestID, data: emptyStringData)

    await fulfillment(of: [errorExpectation], timeout: 1.0)
    task.cancel()
}

@MainActor
func test_value_withStringProperty_throwsMissingDataOnEmptyString() async throws {
    let mockCommandQueue = MockCommandQueue()
    var capturedObserver: ViewModelInstanceListener?

    let viewModelInstance = makeViewModelInstance(mockCommandQueue: mockCommandQueue) { observer in
        capturedObserver = observer
    }

    mockCommandQueue.stubRequestViewModelInstanceString { instanceHandle, path, requestID in
        let mockData = MockRiveViewModelInstanceData(stringValue: nil)
        capturedObserver?.onViewModelDataReceived(instanceHandle, requestID: requestID, data: mockData)
    }

    let property = StringProperty(path: "childVm/name")

    do {
        _ = try await viewModelInstance.value(of: property)
        XCTFail("Expected ViewModelInstanceError.missingData to be thrown")
    } catch let error as ViewModelInstanceError {
        guard case .missingData = error else {
            XCTFail("Expected missingData error, got \(error)")
            return
        }
    }
}

Source .riv/.rev file

Any .riv file with a ViewModel containing a string property will reproduce this. Nested paths (e.g. "childVm/name") are confirmed affected.

Expected behavior

Setting a string property to "" should emit "" through the stream. An empty string is a valid value.

Suggested fix

In RiveViewModelInstanceData.mm, always set _stringValue for string/enum types:

case rive::DataType::string:
case rive::DataType::enumType:
    _stringValue = [NSString stringWithUTF8String:data.stringValue.c_str()];
    break;

[NSString stringWithUTF8String:""] correctly produces @"" (non-nil empty NSString).

Device & Versions (please complete the following information)

  • Device: iOS Simulator (iPhone 16)
  • iOS version: iOS 18.2
  • rive-ios: latest main (397b290)
  • API: @_spi(RiveExperimental)

Additional context

  • The same bug likely affects EnumProperty since it shares the same code path
  • The one-shot value(of:) API also returns missingData for empty strings (same root cause)
  • Current workaround is wrapping stream iteration in a retry loop, but this introduces gaps where updates can be missed

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions