Skip to content

Fix flaky ChecklistItem test by properly awaiting act()#9616

Merged
pavelzeman merged 1 commit into
mainfrom
fix/checklist-item-flaky-test
Mar 20, 2026
Merged

Fix flaky ChecklistItem test by properly awaiting act()#9616
pavelzeman merged 1 commit into
mainfrom
fix/checklist-item-flaky-test

Conversation

@pavelzeman

@pavelzeman pavelzeman commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the flaky ChecklistItem › renders the correct checkbox test.

Root Cause

The test used synchronous act() to trigger toggleChecked(), which is an async function that calls setIsChecking(true), awaits updateChecklistItem(), then calls setIsChecking(false). Synchronous act() does not flush microtasks from the async continuation, so setIsChecking(false) never ran within the act boundary — the loading indicator stayed visible and the waitFor assertion timed out.

Fix

Changed both act() calls to await act(async () => ...) so React properly flushes all pending microtasks and state updates before the subsequent assertions.

Testing

CI should confirm the test passes consistently.


Migrated from #9612 (fork → org branch)

Release Note

NONE

@mm-cloud-bot mm-cloud-bot added the kind/bug Categorizes issue or PR as related to a bug. label Mar 20, 2026
@mm-cloud-bot

Copy link
Copy Markdown

@pavelzeman: Adding the "do-not-merge/release-note-label-needed" label because no release-note block was detected, please follow our release note process to remove it.

Details

I understand the commands that are listed here

@mm-cloud-bot

Copy link
Copy Markdown

@pavelzeman: Adding the "do-not-merge/release-note-label-needed" label because no release-note block was detected, please follow our release note process to remove it.

Details

I understand the commands that are listed here

@coderabbitai

coderabbitai Bot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: faab1c27-4d6e-4331-8efd-d64938155d1a

📥 Commits

Reviewing files that changed from the base of the PR and between dd6c0af and 19b9f0b.

📒 Files selected for processing (1)
  • app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx

📝 Walkthrough

Walkthrough

A test file for the ChecklistItem component was updated to wrap asynchronous checkbox interactions with await act(async () => ...) instead of synchronous act(), ensuring React state updates and promise resolutions are properly flushed during test execution.

Changes

Cohort / File(s) Summary
Checkbox Test Async Handling
app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx
Modified "renders correct checkbox" test flow to wrap checkbox press and promise resolution interactions with async act() to properly flush React state updates and asynchronous transitions.

Estimated code review effort

🎯 1 (Trivial) | ⏱️ ~3 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title directly summarizes the main change—fixing a flaky ChecklistItem test by properly awaiting act()—which matches the file-level changes.
Description check ✅ Passed The description is directly related to the changeset, explaining the root cause of the flakiness and how the fix (converting synchronous act() to async act()) addresses the issue.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/checklist-item-flaky-test

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
patches/react-native-navigation+7.45.0.patch (4)

263-272: ⚠️ Potential issue | 🟠 Major

Guard the NavigationActivity cast in invalidate().

The activity() method now returns a plain Activity, but invalidate() does an unconditional (NavigationActivity) activity() cast. While the patch handles this risk in other methods—using instanceof checks (in navigator()) or try-catch blocks (in handle() and onHostResume())—invalidate() remains unprotected. If bridge teardown runs while a non-NavigationActivity is current, this will throw a ClassCastException.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@patches/react-native-navigation`+7.45.0.patch around lines 263 - 272, The
invalidate() method unconditionally casts activity() to NavigationActivity which
can cause a ClassCastException; before casting, check that activity() is an
instance of NavigationActivity (or null) and only call
navigationActivity.onCatalystInstanceDestroy() when that check passes, otherwise
skip that call; keep the call to super.invalidate() regardless. Reference:
invalidate(), activity(), NavigationActivity, onCatalystInstanceDestroy(),
super.invalidate().

126-228: ⚠️ Potential issue | 🔴 Critical

Navigation commands will hang when no active NavigationActivity exists.

Wrapping navigator calls with ifPresent() causes them to silently no-op when the current activity is not a NavigationActivity. For promise-based commands, NativeCommandListener never runs, so the JavaScript promises never settle. This breaks the error-handling logic already in place at app/screens/navigation.ts:579–590, app/screens/navigation.ts:773–790, and app/products/calls/state/actions.ts:470–475, where the application explicitly handles promise rejections.

Also applies to: lines 233–238 (navigator returns Optional.empty), lines 243–253 (handle catches ClassCastException silently).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@patches/react-native-navigation`+7.45.0.patch around lines 126 - 228, The
navigator Optional wrapping causes commands to silently no-op and leaves
NativeCommandListener (and JS promises) unresolved; instead of replacing calls
like navigator().setRoot(...)/push(...)/mergeOptions(...) with
navigator().ifPresent(...), detect when navigator() is empty and explicitly
invoke the corresponding NativeCommandListener error path so the Promise is
settled: for each call site that now uses navigator().ifPresent(nav ->
nav.<action>(... , new NativeCommandListener(...))), keep creating the same
NativeCommandListener instance and, if navigator().isPresent() call
nav.<action>(...), else call the listener's failure/reject callback (or
otherwise invoke its error handling method) so the JS promise is rejected; apply
this pattern for setRoot, setDefaultOptions, mergeOptions, push, setStackRoot,
pop, popTo, popToRoot, showModal, dismissModal (ensure mergeOptions still runs
against nav when present), dismissAllModals, showOverlay, dismissOverlay and
dismissAllOverlays.

355-378: ⚠️ Potential issue | 🟠 Major

Inconsistent null-safety: gesture callbacks should use safe handling like other methods in the same patch.

Lines 357, 364, and 371 still force-unwrap getEventDispatcher()!!, creating a crash risk. The patch makes the dispatcher nullable and correctly guards it with ?.let in onInterceptTouchEvent() and onTouchEvent() (lines 387–389, 396–398), but the gesture callbacks don't apply the same pattern. Additionally, getEventDispatcher() itself (lines 376–378) can return null but still crashes if UIManagerModule is absent due to the !! force-unwrap on the module.

Update the gesture callbacks to match the safe handling used elsewhere:

androidEvent?.let {
    getEventDispatcher()?.let { dispatcher ->
        mJSTouchDispatcher.onChildStartedNativeGesture(it, dispatcher)
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@patches/react-native-navigation`+7.45.0.patch around lines 355 - 378, The
gesture callbacks onChildStartedNativeGesture and onChildEndedNativeGesture
currently force-unwrap getEventDispatcher() (!!) and risk NPEs; change them to
mirror the safe pattern used elsewhere by null-checking androidEvent and then
safely obtaining the dispatcher (call getEventDispatcher() and .let on it)
before calling
mJSTouchDispatcher.onChildStartedNativeGesture/onChildEndedNativeGesture, and
also remove the forced unwrap when retrieving the UIManagerModule inside
getEventDispatcher so it can return null safely (ensure UIManagerModule lookup
is null-checked before accessing eventDispatcher).

315-321: ⚠️ Potential issue | 🟠 Major

Use ime.bottom instead of hardcoded 0 for bottom padding.

When SOFT_INPUT_ADJUST_NOTHING is set, the system will not pan the layout for the keyboard. The code here manually applies IME insets as padding to accommodate it, but sets bottom padding to 0 instead of ime.bottom. The IME inset's bottom value represents keyboard height (zero when hidden, equals keyboard height when visible). Without this value, non-root views will still be covered by the keyboard.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@patches/react-native-navigation`+7.45.0.patch around lines 315 - 321, The
bottom padding is incorrectly hardcoded to 0 when applying IME insets; in the
block that gets Activity via getActivity(), computes Insets ime =
insets.getInsets(WindowInsetsCompat.Type.ime()), and checks softInputMode ==
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING && !isRoot(), change the
view.setPadding call to use ime.bottom for the bottom padding instead of 0 so
the keyboard height is respected, then return WindowInsetsCompat.CONSUMED as
before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ios/Gekidou/Sources/Gekidou/Storage/Database.swift`:
- Around line 178-180: The code currently materializes rows with
Array(iterator).map which uses SQLite.swift's non-throwing next() and can hide
SQLITE_BUSY; replace each Array(iterator).map usage (e.g., where you call let
iterator = try db.prepareRowIterator(query) and then let servers: [T] = try
Array(iterator).map { row in return try row.decode() }) with an explicit
failableNext() loop: repeatedly call try iterator.failableNext() in a while-let
loop, decode each yielded row via row.decode() and append to a local results
array, then return that array; apply the same replacement in Database.swift
(both occurrences around db.prepareRowIterator), Database+Users.swift (the line
using Array(iterator)), and the test file DatabaseSafeIterationTests.swift to
ensure every Array(iterator) materialization is converted to the failableNext()
loop.

In `@ios/Gekidou/Tests/GekidouTests/DatabaseSafeIterationTests.swift`:
- Around line 136-154: The test must not call sqlite3_close_v2(conn.handle)
because that violates Connection ownership; instead simulate a SQLite error
through the public API (e.g., create a second Connection to the same db file,
execute a locking statement like "BEGIN IMMEDIATE" on that second Connection to
force a SQLITE_LOCKED condition, then call iterator.failableNext() and assert it
throws with XCTAssertThrowsError or returns nil as appropriate). Remove the
direct sqlite3_close_v2 call and replace the loose success path in the do/catch
with a proper assertion that fails if no error is thrown; keep cleanup (try?
FileManager.default.removeItem(atPath: dbPath)) but ensure it runs after
restoring/removing the lock.
- Around line 161-198: The test testBusyTimeoutHandlesConcurrentAccess currently
uses WAL and manually-created Connection instances so readers never hit
busy-timeout; change it to open connections via the production helper
Database.openConnection(...) and create a real contention scenario (e.g., set
PRAGMA journal_mode=DELETE or ensure WAL is off, start an exclusive/IMMEDIATE
write transaction on the writer Connection to hold a lock, then attempt a read
on the reader opened through Database.openConnection with a short busyTimeout)
so the reader actually blocks and exercises the busyTimeout path; reference the
testBusyTimeoutHandlesConcurrentAccess function, the writer/reader Connection
instances, busyTimeout property, PRAGMA journal_mode setting, and
Database.openConnection(...) when making the edits.

---

Outside diff comments:
In `@patches/react-native-navigation`+7.45.0.patch:
- Around line 263-272: The invalidate() method unconditionally casts activity()
to NavigationActivity which can cause a ClassCastException; before casting,
check that activity() is an instance of NavigationActivity (or null) and only
call navigationActivity.onCatalystInstanceDestroy() when that check passes,
otherwise skip that call; keep the call to super.invalidate() regardless.
Reference: invalidate(), activity(), NavigationActivity,
onCatalystInstanceDestroy(), super.invalidate().
- Around line 126-228: The navigator Optional wrapping causes commands to
silently no-op and leaves NativeCommandListener (and JS promises) unresolved;
instead of replacing calls like
navigator().setRoot(...)/push(...)/mergeOptions(...) with
navigator().ifPresent(...), detect when navigator() is empty and explicitly
invoke the corresponding NativeCommandListener error path so the Promise is
settled: for each call site that now uses navigator().ifPresent(nav ->
nav.<action>(... , new NativeCommandListener(...))), keep creating the same
NativeCommandListener instance and, if navigator().isPresent() call
nav.<action>(...), else call the listener's failure/reject callback (or
otherwise invoke its error handling method) so the JS promise is rejected; apply
this pattern for setRoot, setDefaultOptions, mergeOptions, push, setStackRoot,
pop, popTo, popToRoot, showModal, dismissModal (ensure mergeOptions still runs
against nav when present), dismissAllModals, showOverlay, dismissOverlay and
dismissAllOverlays.
- Around line 355-378: The gesture callbacks onChildStartedNativeGesture and
onChildEndedNativeGesture currently force-unwrap getEventDispatcher() (!!) and
risk NPEs; change them to mirror the safe pattern used elsewhere by
null-checking androidEvent and then safely obtaining the dispatcher (call
getEventDispatcher() and .let on it) before calling
mJSTouchDispatcher.onChildStartedNativeGesture/onChildEndedNativeGesture, and
also remove the forced unwrap when retrieving the UIManagerModule inside
getEventDispatcher so it can return null safely (ensure UIManagerModule lookup
is null-checked before accessing eventDispatcher).
- Around line 315-321: The bottom padding is incorrectly hardcoded to 0 when
applying IME insets; in the block that gets Activity via getActivity(), computes
Insets ime = insets.getInsets(WindowInsetsCompat.Type.ime()), and checks
softInputMode == WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING &&
!isRoot(), change the view.setPadding call to use ime.bottom for the bottom
padding instead of 0 so the keyboard height is respected, then return
WindowInsetsCompat.CONSUMED as before.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5e07d31c-d8d0-4f59-81c2-2285a9913f6a

📥 Commits

Reviewing files that changed from the base of the PR and between 1c73a26 and dd6c0af.

📒 Files selected for processing (10)
  • app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Mentions.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+System.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Thread.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database.swift
  • ios/Gekidou/Tests/GekidouTests/DatabaseSafeIterationTests.swift
  • patches/react-native-navigation+7.45.0.patch

Comment thread ios/Gekidou/Sources/Gekidou/Storage/Database.swift Outdated
Comment thread ios/Gekidou/Tests/GekidouTests/DatabaseSafeIterationTests.swift Outdated
Comment thread ios/Gekidou/Tests/GekidouTests/DatabaseSafeIterationTests.swift Outdated
@github-actions

github-actions Bot commented Mar 20, 2026

Copy link
Copy Markdown

Coverage Comparison Report

Generated on March 20, 2026 at 20:02:11 UTC

+-----------------+------------+------------+-----------+
| Metric          | Main       | This PR    | Diff      |
+-----------------+------------+------------+-----------+
| Lines           |     85.15% |     85.15% |     0.00% |
| Statements      |     85.01% |     85.01% |     0.00% |
| Branches        |     72.05% |     72.05% |     0.00% |
| Functions       |     83.99% |     83.99% |     0.00% |
+-----------------+------------+------------+-----------+
| Total           |     81.55% |     81.55% |     0.00% |
+-----------------+------------+------------+-----------+

@mm-cloud-bot

Copy link
Copy Markdown

@pavelzeman: Adding the "do-not-merge/release-note-label-needed" label because no release-note block was detected, please follow our release note process to remove it.

Details

I understand the commands that are listed here

@pavelzeman

Copy link
Copy Markdown
Contributor Author

Note: CodeRabbit review comments on this PR about SQLite safe iteration (Database.swift, DatabaseSafeIterationTests.swift) belong to #9610 — they appear here because this branch includes #9610's commits as ancestors. All three findings have been addressed in #9610.

@larkox larkox left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another PR where the actual fix got code bleeds from other PRs. The change here should be only on checklist_item.test.tsx, but I saw this same change on the "fix db" PR. Apart of that, LGTM.

@pavelzeman

Copy link
Copy Markdown
Contributor Author

Another PR where the actual fix got code bleeds from other PRs. The change here should be only on checklist_item.test.tsx, but I saw this same change on the "fix db" PR. Apart of that, LGTM.

You're right - I was cherry-picking the "flaky test" to get to a clean CI. I'll clean that up before merging.

The 'renders the correct checkbox' test was flaky because it used
synchronous act() to trigger async state updates. When toggleChecked()
is called, it sets isChecking=true, awaits updateChecklistItem(), then
sets isChecking=false. The synchronous act() doesn't flush microtasks
from the async continuation, so setIsChecking(false) never executes
within the act boundary, leaving the loading indicator visible.

Changed both act() calls to await act(async () => ...) so React
properly flushes all pending microtasks and state updates.

Co-authored-by: Claude <claude@anthropic.com>
@pavelzeman
pavelzeman force-pushed the fix/checklist-item-flaky-test branch from dd6c0af to 19b9f0b Compare March 20, 2026 19:51
@pavelzeman
pavelzeman merged commit d8ee8fd into main Mar 20, 2026
15 checks passed
@pavelzeman
pavelzeman deleted the fix/checklist-item-flaky-test branch March 20, 2026 20:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/bug Categorizes issue or PR as related to a bug. release-note-none

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants