履歴位置のリフレッシュでスナップショットを復元せず最新ページを取得

hotwired/hotwire-native-ios

Turbo の refreshVisitable がスナップショットキャッシュを復元していた問題を解消し、.replace アクションで常にサーバーの最新状態を取得できるようにしました。この変更により、非安全なフォーム送信後のリダイレクトでも古いキャッシュが表示されることがなくなります。

背景

refreshVisitable は Turbo のバックナビゲーションと同様に .restore アクションを使用していました。.restore はキャッシュされたスナップショットが存在すればそれを優先的に表示し、履歴スタックには新たなエントリーを追加しませんでした。非安全な(POST/PUT/DELETE/PATCH)フォーム送信後にサーバーが /refresh_historical_location にリダイレクトすると、ユーザーは 「リフレッシュ」 という意図でページの最新状態を期待しますが、キャッシュが残っていると古いコンテンツが再表示される不整合が発生しました。

この不整合は、/page_1 → /page_2 の遷移後にフォーム送信で /page_1 にリダイレクトした際、2回目以降にキャッシュが復元されて新しいサーバー応答が隠れるという形で再現されました(#234 での報告参照)。その結果、ユーザー体験が損なわれるだけでなく、Turbo の仕様(非安全な送信後はキャッシュをクリアして再取得)と iOS 実装の挙動が乖離していました。

技術的な変更

Navigator.swiftrefreshVisitable メソッドで .restore.replace へアクションを置き換えました。.replace はキャッシュを無視して対象ページを再取得し、履歴スタックに追加しない点は .restore と同様です。具体的な差分は以下の通りです。

@@ -253,9 +253,9 @@ extension Navigator: NavigationHierarchyControllerDelegate {
     func refreshVisitable(navigationStack: NavigationHierarchyController.NavigationStackType, newTopmostVisitable: any Visitable) {
         switch navigationStack {
         case .main:
-            session.visit(newTopmostVisitable, action: .restore)
+            session.visit(newTopmostVisitable, action: .replace)
         case .modal:
-            modalSession.visit(newTopmostVisitable, action: .restore)
+            modalSession.visit(newTopmostVisitable, action: .replace)
         }
     }
 }

テストコードも同様に期待アクションを .replace に更新し、変更が正しく反映されていることを検証しています。

@@ -87,7 +87,7 @@ final class NavigationHierarchyControllerHistoricalLocationTests: XCTestCase {
         XCTAssertTrue(session.visitWasCalled)
-        XCTAssertEqual(session.visitAction, .restore)
+        XCTAssertEqual(session.visitAction, .replace)
     }

これにより、手動テストと自動テストの両方で、リフレッシュ後にスナップショットが利用されず新しいページが描画されることが保証されます。

設計判断

アクション置換のみで対応 する方針が採られました。代替案として新しい設定キーやオプションフラグを導入する検討もありましたが、.replace は既存の TurboSession.visit(_:action:) に既に実装されているため、ロジックの追加や後方互換性のリスクを最小化できます。さらに、.replace は履歴スタックへの追加を行わない点で .restore と同等の振る舞いを保ちつつ、キャッシュ無視という期待動作を実現します。

この選択は「最小限の変更で期待される振る舞いを満たす」ことを優先し、コードベース全体への影響を限定的に抑える設計判断といえます。結果として、iOS 側の挙動が Turbo の仕様と一致し、プラットフォーム間の一貫性が向上しました。

まとめ

refreshVisitable.restore を使っていたために発生していたスナップショット再利用のバグを、.replace に置き換えるだけで解消しました。これにより、非安全なフォーム送信後のリフレッシュが常に最新サーバー状態を取得し、ユーザーが期待する「ページのリロード」動作を提供します。変更はコード規模が小さく、既存テストでカバーされているためリリースリスクも低く、今後の開発で同様のシナリオが安全に扱える基盤が整いました。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
53581fcf

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文、背景、技術的な変更、設計判断(任意)、まとめの順に構成されており、必須要素がすべて揃っている。

カスタムMarkdown構文 ⚠ WARNING

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックはdiff形式で正しく記載されているが、PRリンクの記法が `[PR #241](URL)` となっており、推奨の `[#241](URL)` 形式と完全に一致していない。致命的ではないが修正が望ましい。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

iOS 開発者向けの専門的な内容で、余計な初心者向け解説はなく、対象読者に適合している。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションは総論・各論・結論のパラグラフ構成が取れており、トピックセンテンスで始まり、1段落1トピック、段落長も適切で空行で区切られている。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内の diff コードブロックは提供された Diff と完全に一致しており、ファイル名や行番号の食い違いはない。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

使用している技術用語は PR 内容と一致し、誤用や不適切な表現は見られない。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

変更の目的・効果・テスト方針が PR の記述と合致しており、技術的に正確な説明がなされている。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内の主張はすべて PR のタイトル・説明・Diff で裏付けられており、根拠のない推測や外部知識の付加はない。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR 番号 #241、関連 Issue #234 などの数値は正しく記載されている。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルは PR の要旨「refresh_historical_location のスナップショット復元を修正」ことを日本語で正確に表現している。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

バージョンサポートやリリース日程といった PR に記載のない外部知識は含まれていない。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現の歪曲はなく、PR の記述と整合している。