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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions