Skip to content
Draft
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
141 changes: 141 additions & 0 deletions tests/unit/README_RaceConditionTest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Onyx sourceValue issues

These tests demonstrate and prove multiple issues with Onyx sourceValue handling:
1. **Race Condition**: Multiple discrete updates batched → only first `sourceValue` visible
2. **Logic Bug**: `useSidebarOrderedReports` conditional logic ignores available `sourceValues`
3. **Stale sourceValues**: `sourceValue` preserves the keys of the latest onyx update during unrelated rerenders

See the thread in [#quality](https://expensify.slack.com/archives/C05LX9D6E07/p1755792968968239?thread_ts=1755543034.080259&cid=C05LX9D6E07) for more info

## Test Files

**`simpleSourceValueRaceConditionDemo.ts`** - Pure race condition test proving batching loses intermediate `sourceValues`
**`useSidebarOrderedReportsVulnerability.ts`** - Logic bug and compound issue tests replicating production patterns
**`staleSourceValueTest`** - Test demonstrating that sourceValue persists during unrelated renders, leading to unnecessary cache busing

## How to Run the Tests

```bash
# Run the race condition test
npm test -- tests/unit/simpleSourceValueRaceConditionDemo.ts

# Run the useSidebarOrderedReports display bug tests
npm test -- tests/unit/useSidebarOrderedReportsDisplayBug.ts

# Run the staleSourceValueTest tests
npm test -- tests/unit/staleSourceValueTest.ts

# Or run all 3 at once
npm test -- tests/unit/simpleSourceValueRaceConditionDemo.ts tests/unit/useSidebarOrderedReportsDisplayBug.ts tests/unit/staleSourceValueTest.ts
```

# The race condition test and what it proves

### The Race Condition Mechanism

1. **Multiple Discrete Updates**: The test performs 3 separate Onyx operations:
- `Onyx.merge(collection_item1)` - Add first collection item
- `Onyx.merge(collection_item2)` - Add second collection item
- `Onyx.merge(collection_item3)` - Add third collection item

2. **React Batching**: Due to `unstable_batchedUpdates` and setTimeout-based batching in Onyx, all updates get batched into a single render

**How this works internally:**
- When Onyx updates occur, they're queued in `batchUpdatesQueue` (in `OnyxUtils.ts`)
- `maybeFlushBatchUpdates()` uses `setTimeout(0)` to defer processing to the next event loop tick
- Inside that timeout, `unstable_batchedUpdates(() => { updatesCopy.forEach(applyUpdates) })` wraps all queued updates
- This forces React to treat all updates as a single batch, triggering only one render
- The `sourceValue` gets set by the first update, then overwritten by subsequent updates, but only the final state is visible to the component

3. **Lost sourceValue Information**: Only the first `sourceValue` is visible to the component, losing information about subsequent updates

### Expected vs Actual Behavior

**Expected** (without race condition):
```
Update 1: sourceValue = {test_items_item1: {step: 1, status: 'started'}}
Update 2: sourceValue = {test_items_item2: {step: 2, status: 'processing'}}
Update 3: sourceValue = {test_items_item3: {step: 3, status: 'completed'}}
```

**Actual** (with race condition):
```
Single Render: sourceValue = {test_items_item1: {step: 1, status: 'started'}}
// Lost: step 2 and step 3 information!
```

## Test Output Example

When you run the simple demo test, you'll see output like:

```
=== Starting the race condition test ===
About to perform 3 discrete updates that should be batched...

=== RESULTS ===
Expected: 3 discrete updates → 3 different sourceValues
Actual: 1 sourceValue(s) received
Renders: 1 (due to React batching)

SourceValues received: [
{
renderCount: 3,
sourceValue: { test_items_item1: { step: 1, status: 'started', message: 'First update' } },
timestamp: 1703123456789
}
]
Final data: {
test_items_item1: { step: 1, status: 'started', message: 'First update' },
test_items_item2: { step: 2, status: 'processing', message: 'Second update' },
test_items_item3: { step: 3, status: 'completed', message: 'Third update' }
}
Final sourceValue: { test_items_item1: { step: 1, status: 'started', message: 'First update' } }

🚨 RACE CONDITION CONFIRMED:
• Expected to see 3 sourceValues
• Actually received 1 sourceValue(s)
• Lost 2 intermediate updates
• Only the FIRST update is visible in sourceValue due to batching!

This means components cannot reliably track state transitions when updates are batched!
```

## Technical Deep Dive: The Batching Mechanism

### Where `unstable_batchedUpdates` is Called

The race condition is caused by Onyx's internal batching mechanism in `lib/OnyxUtils.ts`:

```typescript
// In OnyxUtils.ts, lines ~203-226
function maybeFlushBatchUpdates(): Promise<void> {
if (batchUpdatesPromise) {
return batchUpdatesPromise;
}
batchUpdatesPromise = new Promise((resolve) => {
setTimeout(() => { // ⚠️ Key: Delays execution to next tick
const updatesCopy = batchUpdatesQueue;
batchUpdatesQueue = [];
batchUpdatesPromise = null;

unstable_batchedUpdates(() => { // ⚠️ React batching starts here
updatesCopy.forEach((applyUpdates) => {
applyUpdates(); // All updates execute together
});
});
resolve();
}, 0); // Next tick of event loop
});
return batchUpdatesPromise;
}
```

### Why This Causes the Race Condition

1. **Multiple Updates Queued**: Each `Onyx.merge()` call adds an update to `batchUpdatesQueue`
2. **setTimeout Delay**: All updates wait for the next event loop tick
3. **Batch Execution**: `unstable_batchedUpdates` executes all updates synchronously within React's batching context
4. **Single Render**: React sees all state changes as one update, triggering only one render
5. **Lost sourceValues**: Only the first `sourceValue` assignment survives the batching process

This is why the test demonstrates that 3 discrete updates result in only 1 `sourceValue` being visible to components.
Loading
Loading