Skip to content

Fix: Restoration doesn't remember place in thread, goes back to the main forum list on every open#1236

Merged
nolanw merged 9 commits intomainfrom
restore-bug
Apr 8, 2026
Merged

Fix: Restoration doesn't remember place in thread, goes back to the main forum list on every open#1236
nolanw merged 9 commits intomainfrom
restore-bug

Conversation

@dfsm
Copy link
Copy Markdown
Contributor

@dfsm dfsm commented Apr 8, 2026

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 UIStateRestoration machinery with UIScene-based restoration. On cold launch the scene's NSUserActivity is replayed through AwfulURLRouter, 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 via AppDelegate, routes deep links / shortcuts / handoff, and handles stateRestorationActivity(for:). Restoration payload stores the primary route as an httpURL string plus scroll fraction, hidden-posts count, and unpop-stack routes in userInfo.
  • AppDelegate — trimmed: removed all UIStateRestoration hooks (shouldSaveApplicationState, willEncodeRestorableStateWith, viewControllerWithRestorationIdentifierPath, etc.), openCopiedURLController moved to SceneDelegate, InterfaceVersion enum deleted. Split the old didFinishLaunchingWithOptions into willFinishLaunchingWithOptions + installInitialRootViewController(in:) called by the scene delegate.
  • RestorableLocation protocol — new. View controllers that know how to express their current location as an AwfulRoute adopt it (ForumsTableViewController, BookmarksTableViewController, ThreadsTableViewController, MessageListViewController, SettingsViewController, PostsPageViewController, MessageViewController).
  • RootViewControllerStack — exposes currentRestorationRoute, topPostsPageViewController, topMessageViewController, currentPrimaryNavigationController to the scene delegate. All restorationIdentifier / restorationClass assignments removed; viewControllerWithRestorationIdentifierPath and PassthroughViewController.encodeRestorableState deleted.
  • NavigationController — swipe-to-unpop stack is now exposed as unpopRoutes: [AwfulRoute] / setUnpopStack(_:) and persisted through the scene activity instead of encodeRestorableState.
  • PostsPageViewController / MessageViewController — drop UIViewControllerRestoration; expose restorationRoute, currentScrollFraction, and prepareForRestoration(scrollFraction:hiddenPosts:) which stages values to apply once the WKWebView finishes rendering. Legacy encodeRestorableState / decodeRestorableState, the restoringState flag, and the ThreadPage nsCoderIntValue helpers are gone.
  • Compose VCs (ComposeTextViewController, CompositionViewController, MessageComposeViewController, ThreadComposeViewController, AnnouncementViewController, ThreadsTableViewController, MessageListViewController, ForumsTableViewController, etc.) — removed every restorationIdentifier, restorationClass, UIViewControllerRestoration conformance, and associated encode/decode pairs.
  • Info.plist — adds UIApplicationSceneManifest with a single-window scene configuration pointing at SceneDelegate.

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 from replies/<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 + new NewThreadDraft — persists subject, body, primary & secondary thread tags keyed by forum ID (newThreads/<forumID>). Draft deleted on successful post.
  • MessageComposeViewController + new PrivateMessageDraft — persists recipient, subject, body, thread tag keyed by kind (messages/new, messages/to/<userID>, messages/replying/<messageID>, messages/forwarding/<messageID>). Draft deleted on send.
  • All three flush the pending auto-save in viewWillDisappear when the VC is actually moving off-screen.
  • New action sheet for PM cancelMessageComposeViewController.cancel() now shows a Delete / Save Draft / Cancel sheet (previously silent), with the delete path correctly telling MessageListViewController to drop its cached compose controller via shouldKeepDraft: false. New localized string compose.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 and Stop in Xcode leave session.stateRestorationActivity nil. SceneDelegate.sceneDidEnterBackground now snapshots the same activity payload into UserDefaults and scene(_:willConnectTo:) falls back to it if UIKit's copy is missing. Cleared on successful consume.

AwfulRoute parser coverage

AwfulRoute.httpURL was emitting real Forums URLs for tab routes (bookmarkthreads.php, usercp.php, bare private.php, empty path) that the HTTP parser in AwfulRoute.init(_:) didn't recognize, so scene restoration couldn't round-trip .bookmarks, .forumList, .messagesList, or .settings. Added parser cases:

  • "" / "/".forumList
  • /bookmarkthreads.php.bookmarks
  • /private.php with action=show + privatemessageid.message(id:), else → .messagesList
  • /usercp.php.settings

Incidentally broadens the URL router's coverage for external links and handoff.

Bug fixes uncovered during review

  • Restored scroll fraction was being clobbered. PostsPageViewController.loadPage's network-completion block saves the current scroll offset to scrollToFractionAfterLoading after a cached-then-fresh re-render, so the user doesn't jump on refresh. During restoration the URL router starts the load before SceneDelegate can stage the saved fraction, so that completion would overwrite the restored value with zero. Added suppressNextScrollFractionPreservation flag, set in prepareForRestoration and consumed once.
  • didAppear() was firing on every foregrounding (previously only called once from didFinishLaunchingWithOptions). It force-adjusts split-view preferredDisplayMode and 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 on ForumsClient.shared.isLoggedIn for symmetry with scene(_:openURLContexts:).
  • pendingRestorationActivity is always cleared, even when the saved activity has no route.

dfsm added 9 commits April 8, 2026 12:01
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.
@nolanw nolanw merged commit 403c527 into main Apr 8, 2026
1 check failed
@nolanw nolanw deleted the restore-bug branch April 8, 2026 06:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants