Fix: Restoration doesn't remember place in thread, goes back to the main forum list on every open#1236
Merged
Fix: Restoration doesn't remember place in thread, goes back to the main forum list on every open#1236
Conversation
iOS 26 no longer reliably replays the legacy NSCoder-based state restoration archive for non-scene apps, so opening a thread and relaunching dropped you back at the forum list. Adopt UIScene with a stateRestorationActivity wrapping an AwfulRoute (plus the current scroll fraction), and replay it on cold launch via the existing AwfulURLRouter.
Strip the legacy NSCoder-based state restoration overrides, restoration identifiers, and registration plumbing from every view controller now that scene-based restoration via NSUserActivity is in place.
Auto-save reply, new-thread, and private-message compose drafts to DraftStore on every text change (debounced 500ms), and load any existing draft when the matching compose flow is opened. Replaces the in-process draft preservation that legacy UIStateRestoration used to provide and that iOS 26 stopped honouring. Add NewThreadDraft and PrivateMessageDraft models to back the two compose flows that previously had no on-disk store.
- Fix race in ReplyWorkspace where a debounced auto-save could fire after a successful Post and resurrect the deleted draft on disk. - Restore the bookmarks tab and a specific forum's thread list on cold launch (BookmarksTableViewController and ThreadsTableViewController now conform to RestorableLocation). - Carry hiddenPosts through the scene restoration activity and apply it after the page finishes loading. - Carry MessageViewController scroll position through the scene restoration activity, mirroring the PostsPageViewController path.
When iOS terminates the scene, save the visible primary navigation controller's swipe-from-right-edge unpop stack as a list of AwfulRoute URL strings in the scene activity userInfo, plus mark each tab-root view controller as a RestorableLocation so being on the bookmarks/messages/forum-list/settings tab without a deeper view controller is also preserved. On restoration, after the main route replay, decode each saved unpop URL back into a route, build a fresh view controller via a small factory, and stuff them into the unpop handler. The push side-effect of the main route replay would otherwise wipe the stack, so the order matters. Routes that need a network round-trip (.post, .profile) or that present as modals (.lepersColony, .rapSheet) are dropped from the unpop stack on restore — the common thread/forum/PM/bookmarks cases all round-trip cleanly.
…ion to avoid clobbering the user's split-view display mode on foregrounding - Flush debounced draft auto-save on Cancel → Save Draft and on compose VC dismissal so the last ~500ms of typing isn't dropped - Gate scene(_:continue:) on isLoggedIn for symmetry with openURLContexts - Always clear pendingRestorationActivity; minor cosmetic cleanup
…ivate.php, empty path) in AwfulRoute.init(_:). Scene restoration round-trips routes through httpURL, and without these parser cases the .bookmarks/.forumList/.messagesList/.settings routes couldn't decode back into an AwfulRoute.
…dPage's network completion saves the current scroll offset to scrollToFractionAfterLoading when re-rendering after a cached render, so the user doesn't jump on refresh. During scene restoration the URL router starts the load before SceneDelegate can stage the saved fraction, so that completion would overwrite the restored value with zero. Suppress the preservation once per restoration.
Mirrors the existing reply-workspace action sheet: tapping Cancel on a non-empty PM compose surface now offers Delete Draft / Save Draft / Cancel instead of silently dismissing. Empty composes dismiss without a prompt and clear any stray empty draft on disk.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
I have tested restoration from various states and all looks good to me. Quite a few changes tho, so submitting as a PR.
AI summary below:
Since the 7.10 TestFlight build, opening Awful no longer drops you back into the thread/page you were last reading — it always opens to the forum list. Awful relies on the legacy UIStateRestoration API (NSCoder-based, driven from AppDelegate):
App/Main/AppDelegate.swift:152-171 — application(_:shouldSaveApplicationState:), shouldRestoreApplicationState, viewControllerWithRestorationIdentifierPath, didDecodeRestorableStateWith.
App/Main/RootViewControllerStack.swift — assigns restorationIdentifiers to the tab bar, root navs, and the empty detail nav (line 92-97), and resolves identifier paths in viewControllerWithRestorationIdentifierPath (line 143-162).
App/View Controllers/Posts/PostsPageViewController.swift:1961-2003, 2156-2173 — encodes threadKey, page, author, scroll fraction, etc., and re-instantiates via UIViewControllerRestoration.
App/Navigation/NavigationController.swift:41, 437-449, 597-603 — sets restorationClass = type(of: self) and supplies its own restored instance.
This API was deprecated in iOS 13 in favour of UIScene + NSUserActivity-based restoration. The app has no UIApplicationSceneManifest in App/Resources/Info.plist, so it has been quietly riding the legacy path for years. The most likely cause of the regression is that iOS 26 no longer reliably persists or replays the legacy NSCoder archive for non-scene apps — none of our local code on this branch touches encodeRestorableState / restorationIdentifier, and the recent Liquid Glass merge (77fdf36) only edited appearance code in NavigationController.swift, not the restoration overrides. The legacy path is on borrowed time regardless; we should stop depending on it.
The good news: Awful already has every piece needed to express "where the user was" as an NSUserActivity → AwfulRoute → URL, and an urlRouter that can navigate to a route from cold launch:
App/Main/Handoff.swift:13-96 — NSUserActivity.route getter/setter (round-trips AwfulRoute through userInfo / webpageURL).
PostsPageViewController.updateUserActivityState (line 1682) builds .threadPage / .threadPageSingleUser routes; MessageViewController and ThreadsTableViewController do the same for their screens.
AppDelegate.open(route:) (line 220) and application(_:continue:restorationHandler:) (line 183) already drive the urlRouter from a route.
The fix is to persist the most-recent meaningful NSUserActivity (as a route URL) and replay it on launch via the existing router, instead of trusting UIStateRestoration to come back.
Scene state restoration (UIScene migration)
Replaces the legacy
UIStateRestorationmachinery with UIScene-based restoration. On cold launch the scene'sNSUserActivityis replayed throughAwfulURLRouter, returning the user to the thread / PM / tab they were last viewing — including scroll position, hidden-posts count, and the swipe-from-right-edge unpop stack.What's in the branch
SceneDelegate— new. Owns the window, installs the root stack viaAppDelegate, routes deep links / shortcuts / handoff, and handlesstateRestorationActivity(for:). Restoration payload stores the primary route as anhttpURLstring plus scroll fraction, hidden-posts count, and unpop-stack routes inuserInfo.AppDelegate— trimmed: removed allUIStateRestorationhooks (shouldSaveApplicationState,willEncodeRestorableStateWith,viewControllerWithRestorationIdentifierPath, etc.),openCopiedURLControllermoved toSceneDelegate,InterfaceVersionenum deleted. Split the olddidFinishLaunchingWithOptionsintowillFinishLaunchingWithOptions+installInitialRootViewController(in:)called by the scene delegate.RestorableLocationprotocol — new. View controllers that know how to express their current location as anAwfulRouteadopt it (ForumsTableViewController,BookmarksTableViewController,ThreadsTableViewController,MessageListViewController,SettingsViewController,PostsPageViewController,MessageViewController).RootViewControllerStack— exposescurrentRestorationRoute,topPostsPageViewController,topMessageViewController,currentPrimaryNavigationControllerto the scene delegate. AllrestorationIdentifier/restorationClassassignments removed;viewControllerWithRestorationIdentifierPathandPassthroughViewController.encodeRestorableStatedeleted.NavigationController— swipe-to-unpop stack is now exposed asunpopRoutes: [AwfulRoute]/setUnpopStack(_:)and persisted through the scene activity instead ofencodeRestorableState.PostsPageViewController/MessageViewController— dropUIViewControllerRestoration; exposerestorationRoute,currentScrollFraction, andprepareForRestoration(scrollFraction:hiddenPosts:)which stages values to apply once theWKWebViewfinishes rendering. LegacyencodeRestorableState/decodeRestorableState, therestoringStateflag, and theThreadPagensCoderIntValuehelpers are gone.ComposeTextViewController,CompositionViewController,MessageComposeViewController,ThreadComposeViewController,AnnouncementViewController,ThreadsTableViewController,MessageListViewController,ForumsTableViewController, etc.) — removed everyrestorationIdentifier,restorationClass,UIViewControllerRestorationconformance, and associated encode/decode pairs.Info.plist— addsUIApplicationSceneManifestwith a single-window scene configuration pointing atSceneDelegate.Draft persistence for compose surfaces
UIStateRestoration used to silently round-trip in-progress composes through its encoder. Without it we'd lose in-progress work on cold launch, so all three compose surfaces now auto-save to
DraftStore(debounced 0.5 s) and load any saved draft on re-entry:ReplyWorkspace— loads fromreplies/<threadID>/edits/<postID>on init. The Cancel → Save Draft / Delete Draft action sheet now flushes the debounced work item before reporting completion so the last keystrokes aren't dropped.ThreadComposeViewController+ newNewThreadDraft— persists subject, body, primary & secondary thread tags keyed by forum ID (newThreads/<forumID>). Draft deleted on successful post.MessageComposeViewController+ newPrivateMessageDraft— persists recipient, subject, body, thread tag keyed by kind (messages/new,messages/to/<userID>,messages/replying/<messageID>,messages/forwarding/<messageID>). Draft deleted on send.viewWillDisappearwhen the VC is actually moving off-screen.MessageComposeViewController.cancel()now shows a Delete / Save Draft / Cancel sheet (previously silent), with the delete path correctly tellingMessageListViewControllerto drop its cached compose controller viashouldKeepDraft: false. New localized stringcompose.draft-menu.private-message.title.Fallback restoration path
stateRestorationActivity(for:)is only called by iOS when the scene is actually disconnected (app-switcher kill, memory reclaim) — not on ordinary backgrounding. Crashes in background andStopin Xcode leavesession.stateRestorationActivitynil.SceneDelegate.sceneDidEnterBackgroundnow snapshots the same activity payload intoUserDefaultsandscene(_:willConnectTo:)falls back to it if UIKit's copy is missing. Cleared on successful consume.AwfulRouteparser coverageAwfulRoute.httpURLwas emitting real Forums URLs for tab routes (bookmarkthreads.php,usercp.php, bareprivate.php, empty path) that the HTTP parser inAwfulRoute.init(_:)didn't recognize, so scene restoration couldn't round-trip.bookmarks,.forumList,.messagesList, or.settings. Added parser cases:""/"/"→.forumList/bookmarkthreads.php→.bookmarks/private.phpwithaction=show+privatemessageid→.message(id:), else →.messagesList/usercp.php→.settingsIncidentally broadens the URL router's coverage for external links and handoff.
Bug fixes uncovered during review
PostsPageViewController.loadPage's network-completion block saves the current scroll offset toscrollToFractionAfterLoadingafter a cached-then-fresh re-render, so the user doesn't jump on refresh. During restoration the URL router starts the load beforeSceneDelegatecan stage the saved fraction, so that completion would overwrite the restored value with zero. AddedsuppressNextScrollFractionPreservationflag, set inprepareForRestorationand consumed once.didAppear()was firing on every foregrounding (previously only called once fromdidFinishLaunchingWithOptions). It force-adjusts split-viewpreferredDisplayModeand could clobber a user-set mode. Now gated on the same one-shot flag as the rest of the connection-launch work.scene(_:continue:)now gates onForumsClient.shared.isLoggedInfor symmetry withscene(_:openURLContexts:).pendingRestorationActivityis always cleared, even when the saved activity has no route.