Skip to content

1.3.0-beta: form submission redirects on the main stack are cancelled by the pop in didProposeVisit #244

Description

@jtreitz

Since 1.3.0-beta, Navigator.session(_:didProposeVisit:) calls pop(animated:) whenever proposal.isRedirect is true (added in 1453caf, PRs #160/#154). isRedirect only checks options.response?.redirected == true, so it fires for every standard form-submission redirect — not just the modal flow the change targeted. This breaks non-modal forms.

Setup

Rails app, all screens on the main stack (no modal rules in the path configuration). Flow: list screen → pushes a form screen → form POSTs → server responds with a standard 303 redirect to the newly created record's show page.

Expected (and actual on 1.2.2 / Android / browser): the show page is pushed; the form screen is no longer on the stack.

Actual on 1.3.0-beta: the record is created, but the user lands back on the form screen.

What happens

Turbo proposes the redirect visit with redirected = 1 and the show page's responseHTML.
didProposeVisit pops the form screen before routing, revealing the screen underneath.
The revealed screen's viewWillAppear triggers a Session restore visit, which cancels the in-flight redirect visit (the response HTML is discarded).
The cancellation cascade ends with the form page re-rendered from Turbo's snapshot cache (hasCachedSnapshot: true).

Pinning back to 1.2.2 (where didProposeVisit just routes the proposal) resolves it. Perhaps the pop should be restricted to the modal-dismissal case it was introduced for, rather than keying off redirected alone?

Debug log of the failing flow (URLs/IDs anonymized, response HTML truncated)

Stack before submit: [/records (list), /records/new?template_id=abc123 (form)]

[Bridge] ← formSubmissionStarted ["location": https://app.example.com/records]
[Bridge] ← formSubmissionFinished ["location": https://app.example.com/records]

# Server redirected the POST to the new record's show page — proposal carries the response
[Bridge] ← visitProposed ["location": https://app.example.com/records/123, "options": {
    action = advance;
    response = { redirected = 1; statusCode = 200; responseHTML = "<!DOCTYPE html>… (full show page) …"; };
    shouldCacheSnapshot = 0;
}]
[Router] handler match found handler: app-navigation location: https://app.example.com/records/123
[Session] visit ["location": https://app.example.com/records/123, "options": VisitOptions(action: advance, response: VisitResponse(statusCode: 200, redirected: true, responseHTML: …))]
[JavascriptVisit] startVisit https://app.example.com/records/123
[Bridge] → window.turboNative.visitLocationWithOptionsAndRestorationIdentifier [".../records/123", { action = advance; response = …; }, nil]

# didProposeVisit's pop(animated:) removes the form screen, revealing the list screen
[Bridge] bridgeDestinationViewWillDisappear: https://app.example.com/records/new?template_id=abc123

# The revealed list screen's viewWillAppear fires a restore visit → cancels the in-flight redirect visit
[JavascriptVisit] cancelVisit https://app.example.com/records/123
[Bridge] → window.turboNative.cancelVisitWithIdentifier [Optional("(pending)")]
[Session] visit ["location": https://app.example.com/records, "options": VisitOptions(action: restore)]
[JavascriptVisit] startVisit https://app.example.com/records
[Bridge] → window.turboNative.visitLocationWithOptionsAndRestorationIdentifier [".../records", { action = restore; }, Optional("3804d359-…")]
[Bridge] bridgeDestinationViewWillAppear: https://app.example.com           ← list screen (pop target)
[Bridge] bridgeDestinationViewDidDisappear: https://app.example.com/records/new?template_id=abc123
[Bridge] bridgeDestinationViewDidAppear: https://app.example.com

# Only now does the pushed show screen load — its visit was already cancelled above
[Bridge] bridgeDestinationViewDidLoad: https://app.example.com/records/123
[Bridge] bridgeDestinationViewWillDisappear: https://app.example.com
[JavascriptVisit] cancelVisit https://app.example.com/records
[Session] visit ["location": https://app.example.com/records/new?template_id=abc123, "options": VisitOptions(action: restore)]
[Bridge] bridgeDestinationViewWillAppear: https://app.example.com/records/123

# The restore visits race; the last one re-renders the submitted form from the snapshot cache
[Bridge] ← visitStarted ["identifier": 6c6c7835-…, "hasCachedSnapshot": 0, "isPageRefresh": 0]
[Bridge] ← visitStarted ["identifier": b4bbb56c-…, "hasCachedSnapshot": 0, "isPageRefresh": 0]
[Bridge] ← visitStarted ["identifier": 899ce949-…, "hasCachedSnapshot": 1, "isPageRefresh": 0]
[Bridge] ← visitCompleted ["identifier": 899ce949-…, "restorationIdentifier": 91c91912-…]
[Bridge] ← visitRendered ["identifier": 899ce949-…]                         ← form page rendered again
[Bridge] bridgeDestinationViewDidDisappear: https://app.example.com
[Bridge] bridgeDestinationViewDidAppear: https://app.example.com/records/123  ← show controller visible, but web view shows the form

The visit for /records/123 never reaches visitStarted — it is cancelled while still pending, and its responseHTML is discarded.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions