Skip to content

Conversation

@aj-rosado
Copy link
Contributor

@aj-rosado aj-rosado commented Nov 28, 2025

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-28835

📔 Objective

On some scenarios if the user tried to multi press the button he can create multiple entries by mistake. This address that issue on item, folder and send creation and also fixes multiple calls to remove password.

⏰ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed
  • Written new unit and / or integration tests where applicable
  • Protected functional changes with optionality (feature flags)
  • Used internationalization (i18n) for all UI strings
  • CI builds passed
  • Communicated to DevOps any deployment requirements
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team

🦮 Reviewer guidelines

  • 👍 (:+1:) or similar for great changes
  • 📝 (:memo:) or ℹ️ (:information_source:) for notes or general info
  • ❓ (:question:) for questions
  • 🤔 (:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
  • 🎨 (:art:) for suggestions / improvements
  • ❌ (:x:) or ⚠️ (:warning:) for more significant problems or concerns needing attention
  • 🌱 (:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt
  • ⛏ (:pick:) for minor or nitpick changes

@claude
Copy link
Contributor

claude bot commented Nov 28, 2025

Claude finished @aj-rosado's task —— View job


Code Review Complete

Tasks

  • Examine current PR changes
  • Launch code review agent
  • Analyze implementation approach
  • Provide comprehensive review feedback

Review Summary

Status: ❌ Changes Requested

All previous review comments from David have been successfully addressed. However, I've identified a critical race condition in the throttling implementation that needs to be fixed before merge, along with some API design improvements.


Critical Issues

1. Race Condition in Throttle Implementation ❌ CRITICAL

Location: ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/ThrottledClick.kt:27-36

The current implementation has a race condition where multiple rapid clicks can bypass throttling:

var isEnabled by remember { mutableStateOf(value = true) }
return {
    coroutineScope.launch {
        if (isEnabled) {           // ← Multiple coroutines can pass this check
            isEnabled = false      // ← before any set it to false
            onClick()
            delay(timeMillis = delayMs)
            isEnabled = true
        }
    }
}

Problem: The check-then-set pattern is not atomic. If a user clicks twice within microseconds, both clicks might see isEnabled = true and both will execute, defeating the purpose of throttling.

Impact: This could still allow duplicate save operations, which is what this PR aims to prevent.

Recommended Fix: Use Mutex for proper synchronization:

@Composable
fun throttledClick(
    onClick: () -> Unit,
    delayMs: Long = 300,
    coroutineScope: CoroutineScope = rememberCoroutineScope(),
): () -> Unit {
    val mutex = remember { Mutex() }
    return {
        coroutineScope.launch {
            if (mutex.tryLock()) {
                try {
                    onClick()
                    delay(timeMillis = delayMs)
                } finally {
                    mutex.unlock()
                }
            }
        }
    }
}

Don't forget to add: import kotlinx.coroutines.sync.Mutex


2. Parameter Ordering Violates Kotlin Conventions ⚠️ MODERATE

Location: ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/ThrottledClick.kt:22-26

The function signature places onClick as the last parameter, but Kotlin conventions dictate that required parameters should come before optional ones.

Current:

fun throttledClick(
    coroutineScope: CoroutineScope = rememberCoroutineScope(),
    delayMs: Long = 300,
    onClick: () -> Unit,  // ← Should be first
): () -> Unit

Expected:

fun throttledClick(
    onClick: () -> Unit,  // ← Required parameter first
    delayMs: Long = 300,
    coroutineScope: CoroutineScope = rememberCoroutineScope(),
): () -> Unit

Benefits: Cleaner call sites without redundant naming:

// Current (verbose)
onClick = throttledClick(onClick = onClick)

// After fix (cleaner)
onClick = throttledClick(onClick)

3. Missing Test Coverage ⚠️ MODERATE

No tests exist for the throttledClick function despite it being critical functionality.

Required test scenarios:

  1. Verify first click executes immediately
  2. Verify rapid clicks within delay window are ignored
  3. Verify clicks after delay window execute successfully
  4. Verify correct delay duration (300ms)
  5. Verify behavior with custom delay values

Recommendation: Add ThrottledClickTest.kt in ui/src/test/kotlin/com/bitwarden/ui/platform/components/util/


Positive Observations ✅

  1. All previous review comments addressed: File renamed, unnecessary changes removed, formatting fixed
  2. Clean module placement: Correctly placed in :ui module under platform/components/util
  3. Good documentation: Includes proper KDoc with parameter descriptions
  4. Consistent integration: Applied uniformly across all three button types
  5. Sensible defaults: 300ms aligns with common UX practices for double-click prevention
  6. Non-invasive approach: Minimal changes to existing button components

Additional Recommendations

Documentation Enhancement

Add usage example and clarify behavior:

/**
 * Creates a throttled click handler that prevents rapid successive clicks.
 *
 * This implements "first-click-then-delay" throttling: the first click executes immediately,
 * then subsequent clicks are ignored for [delayMs] milliseconds.
 *
 * Example:
 * ```
 * Button(onClick = throttledClick(onClick = { saveItem() })) {
 *     Text("Save")
 * }
 * ```
 *
 * @param onClick The action to perform when clicked.
 * @param delayMs The minimum time in milliseconds between clicks (default: 300ms).
 * @param coroutineScope The coroutine scope for launching click handlers.
 * @return A throttled click handler function.
 */

Consider Icon Buttons

Verify whether icon buttons (BitwardenStandardIconButton, BitwardenTonalIconButton, etc.) also need throttling for consistency.


Security & Performance

No security issues identified - The throttling operates purely in the UI layer and doesn't affect the zero-knowledge architecture.

Performance acceptable - Coroutine overhead is minimal, and the memory footprint (one Boolean per button) is negligible.


Overall Assessment

Status: ❌ NOT READY FOR MERGE

Reasoning: The race condition in the core throttling logic is a critical correctness issue. While it may not manifest frequently in normal usage, it defeats the purpose of this PR under heavy rapid clicking.

Effort to Fix: ~3-4 hours

  • Race condition fix: 30-60 minutes
  • Parameter reordering: 15 minutes
  • Test coverage: 1-2 hours

Recommendation: Address the race condition and parameter ordering before merge. Test coverage is highly recommended given this is critical functionality for preventing duplicate operations.


return@onContent
}

if (state.dialog is FolderAddEditState.DialogState.Loading) return@onContent
Copy link
Contributor

Choose a reason for hiding this comment

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

💭 Consider guard clause placement for consistency

Details

The loading state guard is placed between validation and state update. While functionally correct, the other ViewModels in this PR (AddEditSendViewModel:561, VaultAddEditViewModel:403) place the guard immediately before state mutation, after all validations are complete.

Current pattern:

if (content.folderName.isEmpty()) {
    // validation error
    return@onContent
}
if (state.dialog is FolderAddEditState.DialogState.Loading) return@onContent // Line 106
mutableStateFlow.update { ... }

Consistent pattern (optional):

if (content.folderName.isEmpty()) {
    // validation error
    return@onContent
}
// Any other validations...

if (state.dialog is FolderAddEditState.DialogState.Loading) return@onContent
mutableStateFlow.update { ... }

This is a minor stylistic point - the current implementation is correct and safe.

@github-actions
Copy link
Contributor

github-actions bot commented Nov 28, 2025

Logo
Checkmarx One – Scan Summary & Details2f0addeb-221b-40c4-abf9-d244d2d481a7

Great job! No new security vulnerabilities introduced in this pull request

@aj-rosado aj-rosado changed the title [PM-28837] Added validations to prevent duplicate press on buttons [PM-28835] Added validations to prevent duplicate press on buttons Nov 28, 2025
@codecov
Copy link

codecov bot commented Nov 28, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.38%. Comparing base (8bdbccd) to head (94be258).
⚠️ Report is 10 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6209      +/-   ##
==========================================
- Coverage   85.41%   85.38%   -0.03%     
==========================================
  Files         755      755              
  Lines       54114    54109       -5     
  Branches     7803     7795       -8     
==========================================
- Hits        46220    46203      -17     
- Misses       5180     5195      +15     
+ Partials     2714     2711       -3     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

)
}

@Suppress("MaxLineLength")
Copy link
Collaborator

Choose a reason for hiding this comment

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

This suppression is not needed

} returns mockProviderCreateCredentialRequest
}

@Suppress("MaxLineLength")
Copy link
Collaborator

Choose a reason for hiding this comment

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

We don't need this suppression

onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAddEditAction.Common.SaveClick) }
},
isEnabled = state.dialog !is VaultAddEditState.DialogState.Loading,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Realistically, can't we just say:

isEnabled = state.dialog == null,

onClick = remember(viewModel) {
{ viewModel.trySendAction(FolderAddEditAction.SaveClick) }
},
isEnabled = state.dialog !is FolderAddEditState.DialogState.Loading,
Copy link
Collaborator

@david-livefront david-livefront Dec 1, 2025

Choose a reason for hiding this comment

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

Thoughts on handling this inside the button? Adding some throttling inside the various BitwardenButtons?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have tested this and just before navigating back I am able to create a duplicate item if I am spamming the save button.
On current main I can even create two duplicates. The throttle mitigates this a little bit but is not as consistent as current approach.

However... I believe throttle would solve the real world scenarios where this happens

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🤔 should we keep this for save operations (in order to prevent duplicates) but still add throttling for all the buttons?

Copy link
Collaborator

Choose a reason for hiding this comment

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

If the throttling works, I don't think we need to add this extra code, right?

Copy link
Collaborator

@david-livefront david-livefront Dec 2, 2025

Choose a reason for hiding this comment

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

I was playing around with this and something like this should work and we can generically apply it to all of the button components:

@Composable
fun throttledClick(
    coroutineScope: CoroutineScope = rememberCoroutineScope(),
    delayMs: Long = 300,
    onClick: () -> Unit,
): () -> Unit {
    var isEnabled by remember { mutableStateOf(value = true) }
    return {
        coroutineScope.launch {
            if (isEnabled) {
                isEnabled = false
                onClick()
                delay(timeMillis = delayMs)
                isEnabled = true
            }
        }
    }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It does not completely fix it, I have been able to create a duplicate. But unless someone intentionally tries to do it should be fine.

Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

If this bug ever re-appears, we can always increase the delay, but I am hesitant to go much higher than ~300

}
return@onContent
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we drop these unnecessary changes

ProviderCreateCredentialRequest.fromBundle(any())
} returns mockProviderCreateCredentialRequest
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ditto

* @param onClick The action to perform when clicked.
* @return A throttled click handler function.
*/

Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we drop this newline

@@ -0,0 +1,39 @@
package com.bitwarden.ui.platform.components.util
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we rename the file to match the function: ThrottledClick

@aj-rosado aj-rosado added this pull request to the merge queue Dec 3, 2025
@aj-rosado
Copy link
Contributor Author

Thank you @david-livefront

Merged via the queue into main with commit 26e7178 Dec 3, 2025
15 checks passed
@aj-rosado aj-rosado deleted the PM-28833/save-creates-duplicate-entry branch December 3, 2025 18:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants