Skip to content

Snapshot cache shows stale content when redirecting to same URL with session-based changes #178

Description

@jhubert

AI helped me write this... so.. it could be completely wrong. But, since I don't know enough about the way stacks work in iOS I'm going to go with it. 😬

Problem

When a server redirects to a URL that's already in the navigation stack with new session-based content (like flash messages or validation errors), Hotwire Native shows the cached snapshot instead of loading the fresh content. This results in users not seeing important feedback messages.

Steps to Reproduce

  1. User uses a deep link to visit a URL that they don't have access to. i.e. /no-access
  2. Server redirects back to /session/new with a flash error message
  3. Hotwire Native shows the cached /session/new from the stack without the flash message
  4. User sees no error feedback and is confused

Current Behavior

When navigating to a URL that exists in the navigation stack, Hotwire Native restores the cached snapshot, which doesn't include session-based changes like:

  • Flash messages
  • Validation errors
  • CSRF token updates
  • Any other session-dependent content

Expected Behavior

Hotwire Native should detect when the same URL has different content and load fresh instead of using the snapshot cache.

Proposed Solution

Add server-driven cache control through response headers that Hotwire Native respects:

1. Rails Side - Add header when content differs

# In ApplicationController or Turbo::Redirection
class ApplicationController < ActionController::Base
  after_action :set_turbo_cache_headers

  private

  def set_turbo_cache_headers
    response.headers['X-Turbo-Clear-Cache'] = 'true'  if flash.any?
  end
end

2. Hotwire Native - Respect cache control headers

// In Session.swift
public func visit(_ visitable: Visitable, options: VisitOptions? = nil, reload: Bool = false) {
    // Check for cache control headers
    let shouldClearCache = options?.response?.responseHeaders?["X-Turbo-Clear-Cache"] != nil

    if reload || shouldClearCache {
        initialized = false
        markSnapshotCacheAsStale()
    }

    let visit = makeVisit(for: visitable, options: options ?? VisitOptions())
    currentVisit?.cancel()
    currentVisit = visit
    visit.start()
}

// In NavigationHierarchyController.swift
private func visitProposal(from properties: PathProperties, with controller: UIViewController) -> VisitProposal {
    let visit = VisitProposal(
        path: properties.href,
        action: properties.action,
        cachePolicy: extractCachePolicy(from: properties)
    )
    // ... rest of implementation
}

private func extractCachePolicy(from properties: PathProperties) -> CachePolicy {
    // Check response headers for cache directives
    if properties.responseHeaders?["X-Turbo-Clear-Cache"] != nil {
        return .requireFresh
    }
    return .default
}

Alternative Solutions

Option A: Content-based cache invalidation

Compare content hashes or ETags to determine if cached content is stale.

Option B: Enhanced VisitOptions

Extend VisitOptions with explicit cache control:

public struct VisitOptions {
    public enum CachePolicy {
        case useSnapshot      // Default
        case requireFresh     // Always bypass cache
        case conditional      // Check ETag/Last-Modified
    }

    public var action: Action
    public var cachePolicy: CachePolicy = .useSnapshot
}

Option C: Configurable cache behavior

Add configuration to opt into smarter cache handling:

Hotwire.config.snapshotCachePolicy = .smart // or .aggressive, .conservative

Impact

This change would improve user experience for:

  • Form submissions with validation errors
  • Authentication flows with error messages
  • Any redirect-after-post pattern with flash messages
  • Session-dependent content updates

Backwards Compatibility

The proposed solution is fully backwards compatible:

  • Without the header, behavior remains unchanged
  • Apps can opt-in by sending the header
  • No breaking changes to existing API

Related Issues

  • Similar to Turbo's handling of data-turbo-cache="false"
  • Related to overall snapshot cache management
  • Affects both iOS and Android implementations

Questions for Discussion

  1. Should this be a Turbo Rails concern that automatically adds headers?
  2. Should we use existing cache headers (Cache-Control: no-store) instead of custom headers?
  3. Should this behavior be configurable at the Hotwire Native level?
  4. How should this interact with the existing reload parameter?

Example Implementation

I'd be happy to submit a PR with this implementation if the approach is approved. The change would require coordination between:

  • hotwire-native-ios
  • hotwire-native-android
  • turbo-rails (optional, for automatic header injection)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    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