Skip to content

[3.x backport] fix(instant): make forced conversion sheet non-dismissible#126

Merged
mhamann merged 1 commit into
release/3.xfrom
backport/instant-user-conversion-non-dismissible-3.x
May 12, 2026
Merged

[3.x backport] fix(instant): make forced conversion sheet non-dismissible#126
mhamann merged 1 commit into
release/3.xfrom
backport/instant-user-conversion-non-dismissible-3.x

Conversation

@mhamann
Copy link
Copy Markdown
Contributor

@mhamann mhamann commented May 12, 2026

Backports #125 to the v3.x maintenance line.

Cherry-picked from 8731aac (the squash-merge of #125 into main) onto a new release/3.x branch cut from tag 3.14.10. The cherry-pick auto-merged a single context overlap in Rownd.swift (added registerSuperTokensSyncEventHandler() call from #124 isn't present here, since #124 is the v4 breaking change we're deliberately leaving out).

Summary

When Rownd.config.forceInstantUserConversion is enabled, the sign-in sheet now:

  • Cannot be swiped down to dismiss.
  • Cannot be dismissed by tapping outside.
  • Ignores Hub-dispatched closeHubViewController / signOut messages.
  • Auto-closes after the user successfully adds an identifier (authLevel transitions out of .instant), including a retry of the post-auth hide() to handle the race between the Hub's 1.5s timer and UserData.fetch propagating authLevel.

No Hub-side changes required.

Test plan

  • RowndTests/InstantUsersTests — all 7 tests pass (4 existing + 3 new for this fix).
  • SDK builds clean against generic/platform=iOS Simulator.
  • Smoke test on simulator with forceInstantUserConversion = true in the example AppDelegate.

Release path

This PR targets release/3.x (newly created from 3.14.10). Merge → run the project's release tooling against release/3.x to cut e.g. 3.14.11.

🤖 Generated with Claude Code

Summary by Sourcery

Enforce a non-dismissible sign-in sheet for forced instant user conversion and ensure it auto-closes once conversion completes.

Bug Fixes:

  • Prevent the instant-user forced conversion sheet from being dismissed via gestures, background taps, or Hub-driven hide calls while conversion is required.
  • Ensure the forced conversion sheet reliably auto-closes after the user transitions from an instant to a non-instant auth level, even when Hub timers and auth propagation race.

Enhancements:

  • Track and gate the instant-user conversion flow so it triggers at most once per session and releases the lock when conversion completes.

Tests:

  • Add instant-user tests covering lock engagement, release after verified conversion, and once-per-session behavior, including a helper to wait on lock state across async updates.

* fix(instant): make forced conversion sheet non-dismissible

When `forceInstantUserConversion` is enabled, the sign-in sheet must be
non-dismissible until the user actually adds an identifier. Previously
the sheet defaulted to dismissible via swipe, tap-outside, and any Hub
close message, which left customers with large populations of unconverted
instant users.

- BottomSheetViewController gains an `isUserDismissalDisabled` flag that
  disables swipe-to-dismiss and tap-outside-to-dismiss at presentation,
  and that the Hub's `can_touch_background_to_dismiss` message cannot
  re-enable.
- `hideBottomSheet` and `HubViewController.hide()` honor the same lock,
  so Hub-dispatched `closeHubViewController`/`signOut` messages cannot
  close the sheet either.
- InstantUsers observes the post-conversion auth-level transition and
  releases the lock once the user is no longer `.instant`, so the
  standard post-auth auto-close path still runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(instant): apply lock live and re-trigger close on release

Addresses review feedback from PR #125:

- BottomSheetViewController: `isUserDismissalDisabled` now has a `didSet`
  that updates the live `sheetController` so the lock takes effect on an
  already-presented sheet. Track the Hub's last-requested dismissibility
  separately so releasing the lock restores it rather than unconditionally
  re-enabling (Qodo #1, #2).
- Rownd: `releaseForcedConversionLock` now re-invokes `hide()` on the
  active HubViewProtocol so a post-auth auto-close that lost the race
  against `UserData.fetch` propagating `authLevel` still closes the sheet.
  Guarded on the prior locked state so unlocking a non-held lock is a
  no-op (avoids side effects in tests).
- Add `Rownd._bottomSheetIsLocked` internal accessor for tests.
- Tests: cover lock engagement on `.instant`, release on `.verified`,
  and the once-per-session gate after release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 12, 2026

Reviewer's Guide

Backport of the instant-user forced conversion behavior to the 3.x line, introducing a lock around the sign-in bottom sheet so it cannot be user- or Hub-dismissed during forced conversion, and adding tests and state management to ensure the lock engages/release correctly and only once per session.

Sequence diagram for forced conversion bottom sheet lock during instant user conversion

sequenceDiagram
    participant InstantUsers
    participant Rownd
    participant BottomSheetController
    participant HubTimer
    participant HubViewController

    InstantUsers->>Rownd: requestSignInForcedConversion(signInOptions)
    Rownd->>BottomSheetController: isUserDismissalDisabled = true
    Rownd->>Rownd: requestSignIn(signInOptions)

    HubTimer->>HubViewController: hide()
    HubViewController->>BottomSheetController: hideBottomSheet()
    alt [isUserDismissalDisabled]
        HubViewController->>HubViewController: return
    end

    InstantUsers->>Rownd: releaseForcedConversionLock()
    Rownd->>BottomSheetController: isUserDismissalDisabled = false
    Rownd->>HubViewController: hide()
    HubViewController->>BottomSheetController: hideBottomSheet()
    BottomSheetController->>HubViewController: dismiss()
Loading

File-Level Changes

Change Details Files
Introduce a forced-conversion lock around the sign-in bottom sheet and integrate it with the Rownd API surface.
  • Add Rownd.requestSignInForcedConversion to wrap requestSignIn and enable the bottom sheet dismissal lock before showing the Hub.
  • Add Rownd.releaseForcedConversionLock to clear the lock, retry Hub auto-hide if it fired while locked, and expose an internal _bottomSheetIsLocked accessor for tests.
  • Wire the forced-conversion flow in InstantUsers to use requestSignInForcedConversion when an instant user is detected and release the lock when authLevel transitions to a non-instant value.
Sources/Rownd/Rownd.swift
Sources/Rownd/framework/InstantUsers.swift
Make the bottom sheet controller aware of the forced-conversion lock and prevent user/HuB-driven dismissals while locked.
  • Add isUserDismissalDisabled flag and hubRequestedCanTouchToDismiss tracking in BottomSheetViewController, applying the lock to the live sheet controller and resetting on view disappearance.
  • Guard hideBottomSheet and canTouchDimmingBackgroundToDismiss so they respect the forced-conversion lock and avoid re-enabling dismiss-on-background while locked.
  • Update HubViewController.hide to early-return when the bottom sheet lock is active, preventing Hub-dispatched close attempts during forced conversion.
Sources/Rownd/Views/BottomSheetViewController.swift
Sources/Rownd/Views/HubViewController.swift
Extend instant-user tests to cover lock lifecycle and once-per-session behavior, and ensure cleanup between tests.
  • Reset the forced-conversion lock in InstantUsersTests.tearDown to avoid leaking singleton state across tests.
  • Add tests verifying that the lock engages when an instant user conversion triggers, releases when authLevel becomes non-instant, and does not re-trigger after a successful conversion within a session.
  • Introduce a small waitUntil helper for polling asynchronous conditions in tests.
Tests/RowndTests/InstantUsersTests.swift

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Make forced conversion sheet non-dismissible until user converts

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Make forced instant user conversion sheet non-dismissible until conversion completes
• Add lock mechanism to prevent swipe, tap-outside, and Hub-dispatched dismissals
• Auto-release lock when user transitions from instant to verified auth level
• Retry post-auth close on lock release to handle race conditions with Hub timer
Diagram
flowchart LR
  A["User triggers forced conversion"] --> B["Lock engaged on bottom sheet"]
  B --> C["Sheet blocks swipe/tap-outside/Hub close"]
  D["User adds identifier"] --> E["Auth level transitions to verified"]
  E --> F["Lock released"]
  F --> G["Post-auth auto-close proceeds"]
Loading

Grey Divider

File Changes

1. Sources/Rownd/Rownd.swift ✨ Enhancement +22/-0

Add lock control methods to Rownd singleton

• Add requestSignInForcedConversion() to engage the dismissal lock before showing sign-in sheet
• Add releaseForcedConversionLock() to release lock and retry hide() for post-auth close
• Add _bottomSheetIsLocked property to expose lock state for testing

Sources/Rownd/Rownd.swift


2. Sources/Rownd/Views/BottomSheetViewController.swift ✨ Enhancement +33/-0

Implement dismissal lock with Hub preference tracking

• Add isUserDismissalDisabled flag with didSet to apply lock changes to live sheet controller
• Track hubRequestedCanTouchToDismiss separately to restore Hub preference when lock releases
• Disable tap-outside-to-dismiss during presentation if lock is active
• Reset lock state in viewDidDisappear to prevent leakage between presentations
• Block hideBottomSheet() when lock is active
• Prevent Hub's can_touch_background_to_dismiss message from re-enabling dismissal while locked

Sources/Rownd/Views/BottomSheetViewController.swift


3. Sources/Rownd/Views/HubViewController.swift ✨ Enhancement +7/-2

Block Hub-dispatched dismissals when locked

• Add check in hide() to ignore dismissal requests when forced-conversion lock is active
• Log debug message when dismissal is blocked by lock

Sources/Rownd/Views/HubViewController.swift


View more (2)
4. Sources/Rownd/framework/InstantUsers.swift ✨ Enhancement +21/-18

Observe auth level transitions and manage lock lifecycle

• Add hasTriggeredConversion flag to prevent re-triggering conversion after successful release
• Change from first() to continuous sink() to observe auth level transitions
• Call requestSignInForcedConversion() instead of requestSignIn() to engage lock
• Release lock when authLevel transitions from instant to verified/unverified/guest
• Unsubscribe only after successful conversion to prevent premature cleanup

Sources/Rownd/framework/InstantUsers.swift


5. Tests/RowndTests/InstantUsersTests.swift 🧪 Tests +123/-0

Add three new tests for forced conversion lock behavior

• Add releaseForcedConversionLock() call in tearDown() to prevent lock leakage between tests
• Add testLockIsEngagedWhenConversionTriggers() to verify lock engages on instant auth level
• Add testLockReleasesAfterVerifiedConversion() to verify lock releases after successful
 conversion
• Add testConversionDoesNotRetriggerAfterRelease() to verify once-per-session behavior
• Add waitUntil() helper for async condition polling

Tests/RowndTests/InstantUsersTests.swift


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 12, 2026

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0)

Grey Divider


Action required

1. Auto-close can be skipped 🐞 Bug ☼ Reliability
Description
Rownd.releaseForcedConversionLock() retries closing the Hub by calling hide() on
bottomSheetController.controller, but that controller is assigned asynchronously via Task in
displayHub/displayViewControllerOnTop. If authLevel transitions out of .instant before controller is
assigned, the cast to HubViewProtocol fails and the Hub may stay open after conversion.
Code

Sources/Rownd/Rownd.swift[R188-196]

+    internal static func releaseForcedConversionLock() {
+        let wasLocked = inst.bottomSheetController.isUserDismissalDisabled
+        inst.bottomSheetController.isUserDismissalDisabled = false
+        guard wasLocked else { return }
+        // The post-auth auto-close may have fired and been blocked while the lock was held
+        // (Hub schedules hide() 1.5s after `.authentication` but `authLevel` propagates from a
+        // separate UserData.fetch — the timer can lose the race). Retry the close now.
+        (inst.bottomSheetController.controller as? HubViewProtocol)?.hide()
+    }
Evidence
Unlock triggers a single hide() call on an optional controller reference, but the controller
assignment/presentation is performed asynchronously; therefore, a fast authLevel transition can
unlock before the controller exists, making the retry ineffective.

Sources/Rownd/Rownd.swift[172-201]
Sources/Rownd/Rownd.swift[416-455]
Sources/Rownd/framework/InstantUsers.swift[40-59]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`releaseForcedConversionLock()` attempts a single immediate close by calling `hide()` on `inst.bottomSheetController.controller`, but `controller` is only assigned later inside `Task { @MainActor in ... }` in `displayHub()`/`displayViewControllerOnTop()`. If forced-conversion unlock happens before that assignment, the optional cast to `HubViewProtocol` yields `nil` and the auto-close retry becomes a no-op.

## Issue Context
This PR’s goal includes reliably auto-closing after `authLevel` transitions out of `.instant`. The current implementation depends on `bottomSheetController.controller` being set at the time of unlock, which is not guaranteed due to the async Task-based presentation.

## Fix Focus Areas
- Sources/Rownd/Rownd.swift[181-196]
- Sources/Rownd/Rownd.swift[416-455]

### Implementation guidance
Pick one of these robust patterns:
1. **Two-phase hide:** keep the current immediate hide, and also schedule a retry on the next MainActor turn (e.g., `Task { @MainActor in await Task.yield(); (controller as? HubViewProtocol)?.hide() }`).
2. **Pending-hide flag:** store a `pendingForcedConversionHide` flag when unlock happens and `controller` is nil; then consume it in `displayHub()` after `bottomSheetController.controller` is assigned.
3. **Dismiss via presenter state:** if `inst.bottomSheetController.presentingViewController != nil`, dismiss the presented sheet controller directly instead of relying on `controller` casting.

Whichever approach you choose, ensure the Hub closes even when unlock occurs before `displayHub()` has assigned/presented the controller.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • Several of the new instant-user tests share nearly identical setup (store/context creation, config flags, initial dispatches, and delays); consider extracting a small helper or fixture to centralize this logic and make the tests easier to maintain.
  • The tests rely on fixed Task.sleep delays (e.g., 50ms/200ms) before asserting lock state, which could be flaky under load; you might instead rely solely on the waitUntil helper (or a similar condition-based wait) to synchronize on the actual state change rather than elapsed time.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Several of the new instant-user tests share nearly identical setup (store/context creation, config flags, initial dispatches, and delays); consider extracting a small helper or fixture to centralize this logic and make the tests easier to maintain.
- The tests rely on fixed `Task.sleep` delays (e.g., 50ms/200ms) before asserting lock state, which could be flaky under load; you might instead rely solely on the `waitUntil` helper (or a similar condition-based wait) to synchronize on the actual state change rather than elapsed time.

## Individual Comments

### Comment 1
<location path="Sources/Rownd/Views/BottomSheetViewController.swift" line_range="27-36" />
<code_context>
     var latestTargetHeight: CGFloat = 0.9
     var isKeyboardOpen = false

+    // When true, swipe-to-dismiss and tap-outside-to-dismiss are disabled for this presentation,
+    // and the Hub's `can_touch_background_to_dismiss` message cannot re-enable them. Programmatic
+    // dismissal (e.g. after a successful sign-in) is unaffected. Reset on viewDidDisappear.
</code_context>
<issue_to_address>
**issue (bug_risk):** The lock currently also blocks programmatic dismissals, which contradicts the comment and may be too strict.

The docstring says programmatic dismissal is unaffected, but `hideBottomSheet` now returns early when the lock is active:

```swift
def hideBottomSheet(_ completion: (() -> Void)? = nil) {
    if isUserDismissalDisabled {
        logger.debug("Ignoring hideBottomSheet: forced-conversion lock is active")
        return
    }
    sheetController?.dismiss(completion)
}
```
So all calls into `hideBottomSheet` are blocked while locked, and `HubViewController.hide()` already checks `isUserDismissalDisabled`, making this double enforcement. If the goal is to block only user-driven dismissals, consider restricting the lock to user-interaction paths (e.g. `canTouchDimmingBackgroundToDismiss` / gesture logic) and let `hideBottomSheet` always dismiss, or provide a separate API that bypasses the lock for trusted, SDK-initiated closes.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +27 to +36
// When true, swipe-to-dismiss and tap-outside-to-dismiss are disabled for this presentation,
// and the Hub's `can_touch_background_to_dismiss` message cannot re-enable them. Programmatic
// dismissal (e.g. after a successful sign-in) is unaffected. Reset on viewDidDisappear.
// The didSet applies the change to a live sheetController, so toggling the lock after
// presentation still takes effect (and unlock restores the Hub's most recent preference).
var isUserDismissalDisabled: Bool = false {
didSet {
guard isUserDismissalDisabled != oldValue, let sheetController = sheetController else { return }
if isUserDismissalDisabled {
sheetController.setCanTouchDimmingBackgroundToDismiss(false)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): The lock currently also blocks programmatic dismissals, which contradicts the comment and may be too strict.

The docstring says programmatic dismissal is unaffected, but hideBottomSheet now returns early when the lock is active:

def hideBottomSheet(_ completion: (() -> Void)? = nil) {
    if isUserDismissalDisabled {
        logger.debug("Ignoring hideBottomSheet: forced-conversion lock is active")
        return
    }
    sheetController?.dismiss(completion)
}

So all calls into hideBottomSheet are blocked while locked, and HubViewController.hide() already checks isUserDismissalDisabled, making this double enforcement. If the goal is to block only user-driven dismissals, consider restricting the lock to user-interaction paths (e.g. canTouchDimmingBackgroundToDismiss / gesture logic) and let hideBottomSheet always dismiss, or provide a separate API that bypasses the lock for trusted, SDK-initiated closes.

@mhamann mhamann merged commit 537f3a0 into release/3.x May 12, 2026
2 checks passed
Comment thread Sources/Rownd/Rownd.swift
Comment on lines +188 to +196
internal static func releaseForcedConversionLock() {
let wasLocked = inst.bottomSheetController.isUserDismissalDisabled
inst.bottomSheetController.isUserDismissalDisabled = false
guard wasLocked else { return }
// The post-auth auto-close may have fired and been blocked while the lock was held
// (Hub schedules hide() 1.5s after `.authentication` but `authLevel` propagates from a
// separate UserData.fetch — the timer can lose the race). Retry the close now.
(inst.bottomSheetController.controller as? HubViewProtocol)?.hide()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Auto-close can be skipped 🐞 Bug ☼ Reliability

Rownd.releaseForcedConversionLock() retries closing the Hub by calling hide() on
bottomSheetController.controller, but that controller is assigned asynchronously via Task in
displayHub/displayViewControllerOnTop. If authLevel transitions out of .instant before controller is
assigned, the cast to HubViewProtocol fails and the Hub may stay open after conversion.
Agent Prompt
## Issue description
`releaseForcedConversionLock()` attempts a single immediate close by calling `hide()` on `inst.bottomSheetController.controller`, but `controller` is only assigned later inside `Task { @MainActor in ... }` in `displayHub()`/`displayViewControllerOnTop()`. If forced-conversion unlock happens before that assignment, the optional cast to `HubViewProtocol` yields `nil` and the auto-close retry becomes a no-op.

## Issue Context
This PR’s goal includes reliably auto-closing after `authLevel` transitions out of `.instant`. The current implementation depends on `bottomSheetController.controller` being set at the time of unlock, which is not guaranteed due to the async Task-based presentation.

## Fix Focus Areas
- Sources/Rownd/Rownd.swift[181-196]
- Sources/Rownd/Rownd.swift[416-455]

### Implementation guidance
Pick one of these robust patterns:
1. **Two-phase hide:** keep the current immediate hide, and also schedule a retry on the next MainActor turn (e.g., `Task { @MainActor in await Task.yield(); (controller as? HubViewProtocol)?.hide() }`).
2. **Pending-hide flag:** store a `pendingForcedConversionHide` flag when unlock happens and `controller` is nil; then consume it in `displayHub()` after `bottomSheetController.controller` is assigned.
3. **Dismiss via presenter state:** if `inst.bottomSheetController.presentingViewController != nil`, dismiss the presented sheet controller directly instead of relying on `controller` casting.

Whichever approach you choose, ensure the Hub closes even when unlock occurs before `displayHub()` has assigned/presented the controller.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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.

1 participant