iOS Today Page Revalidation
Additive integration guide for Web-owned Today Page refresh feedback in native iOS chrome.
This guide maps the Today Page V2 refresh contract
onto the current today-platform-ios implementation. It extends the existing
soft-refresh path; it does not replace TodayPageRefreshCoordinator, the UIKit
pull-to-refresh control, or the WebView loading gate.
Ownership
- iOS reports facts by sending
todayPage.refreshRequestedwith areason. - Web owns revalidation policy and decides whether feedback is visible.
- iOS renders the instructed native UI and correlates it by
requestId. - The existing UIKit pull-to-refresh spinner remains fully native.
- Periodic polling, tab activation, socket events, and other maintenance refreshes stay silent unless the protocol explicitly says otherwise.
Advertise the capability
Advertise today_page_revalidate_feedback.v1 only after the Today Page bridge can
parse todayPage.refreshStarted and route both start and terminal events to the
header renderer.
The current injection point is
FeedWebView.injectPlatformTagScript(into:). Scope the capability to the Today
Page configuration rather than advertising it from every FeedWebView:
window.__todayWebView = {
platform: 'ios',
surface: 'embedded',
capabilities: ['today_page_revalidate_feedback.v1']
}A deployment that omits the capability keeps the existing behavior. Web still
revalidates and emits todayPage.refreshResult, but does not instruct iOS to show
header feedback.
Extend the native request
Add the protocol vocabulary at the typed boundary:
enum TodayPageRefreshReason: String {
case userPull = "user_pull"
case appForeground = "app_foreground"
case liveWidgetCreated = "live_widget_created"
case tabActivated = "tab_activated"
}
@MainActor
func requestRefresh(requestId: String, reason: TodayPageRefreshReason) {
webView.dispatchNativeEvent(
type: FeedWebView.NativeEventType.todayPageRefreshRequested,
payload: [
"requestId": requestId,
"reason": reason.rawValue,
]
)
}Thread reason through TodayPageRefreshCoordinator.request(...) and
TodayPageWebDriver.requestRefresh(...). Keep the existing UUID generation,
12-second watchdog, and stale-result protection.
Use these call-site mappings:
| iOS trigger | reason | Visible UI |
|---|---|---|
TodayPageWebView.onPullToRefresh / TodayEmptyView.onPullToRefresh | user_pull | Existing pull spinner only |
| Return after at least 5 seconds outside the foreground | app_foreground | Header, when Web accepts the refresh |
| Successful Live Widget creation completion | live_widget_created | Header, when Web accepts the refresh |
| Today tab activation | tab_activated | None |
| Socket push, timer, ordinary maintenance | Do not create a visible reason | None |
Do not infer app_foreground from every viewDidAppear. At the existing app
lifecycle boundary in TodayPageVC+Exposure.swift, record a monotonic timestamp
when the app leaves the foreground. On the matching return, request
app_foreground only when at least 5 seconds elapsed. Initial activation and
shorter app switches do nothing. Clear the recorded timestamp after evaluating
the return so one background interval can produce at most one request.
private enum Timing {
static let foregroundRevalidationThreshold: Duration = .seconds(5)
}Keep socket and timer callers of refreshTodayContent() silent. Web's own
fallback poll runs once per minute and does not produce native header feedback.
Parse Web feedback
Extend TodayPageBridgeEvent at the existing raw-payload boundary:
enum TodayPageRefreshPresentation: String {
case header
}
enum TodayPageBridgeEvent {
case refreshStarted(
requestId: String,
presentation: TodayPageRefreshPresentation
)
case refreshResult(requestId: String, status: String)
// Existing cases remain unchanged.
}For todayPage.refreshStarted, require schemaVersion == 1, a non-empty
requestId, and presentation == "header". Continue ignoring unknown event
types and unknown fields at the bridge boundary. A malformed known event must
not enter the runtime.
Route the new case through TodayPageRuntime.handleBridgeEvent(_:) and
TodayPageVC+WebBridge.applyTodayPageBridgeDirective(_:). Keep raw dictionaries
out of the VC.
Header state machine
Add a small main-actor-owned renderer next to TodayPageHeaderView. It should
render commands, not decide whether a refresh deserves UI:
hidden
└─ refreshStarted(id) ─> refreshing(id)
├─ refreshResult(id, succeeded) ─> updated ── 1 s ─> hidden
├─ refreshResult(id, failed) ────> failed ── 1 s ─> hidden
└─ refreshResult(id, cancelled|unsupported) ─────────> hiddenImplementation requirements:
- A new
refreshStartedreplaces the active headerrequestIdand cancels any pending one-second hide task. - Ignore a stale
refreshResultwhoserequestIddoes not match the active header request. succeededshows the localized updated state for one second, then hides.failedmay show a localized failure state for one second; it must not claim the content was updated.cancelledandunsupportedhide without an updated state.- Clear the header state on main-frame navigation, Web content termination, account switch, and WebView teardown.
TodayPageHeaderView is the native chrome integration point. Keep its existing
layout and hit-testing behavior; add the feedback view as a trailing or adjacent
presentation owned by the header instead of placing native UI inside the Web
document.
Pull-to-refresh stays separate
user_pull deliberately produces no todayPage.refreshStarted. UIKit starts
the spinner from the gesture, and the existing TodayPageRefreshCoordinator
ends it when the matching todayPage.refreshResult arrives or when the watchdog
fires.
The header coordinator and pull coordinator can observe the same terminal event,
but each must settle only its own matching requestId. Do not mirror a user pull
into the header.
Web-originated cached startup
When cached content is available, Web may start its initial background revalidation and emit:
{
"type": "todayPage.refreshStarted",
"schemaVersion": 1,
"requestId": "web-generated-id",
"presentation": "header"
}There is no preceding native request in this case. Accept the Web-generated
requestId, render header feedback, and settle it with the later
todayPage.refreshResult. This covers cache-first cold start and lightweight
app startup without adding another native event source.
Suggested iOS edit map
Views/FeedWebView.swift: add the reason constant and capability-aware document-start tag configuration.TodayPageWebDriver.swift: accept and serializeTodayPageRefreshReason.TodayPageRefreshCoordinator.swift: retain the reason with each request while preserving UUID correlation and the pull watchdog.TodayPageBridgeEvent.swift: parserefreshStartedand typed terminal status.TodayPageRuntime.swift: turn parsed events into header directives.TodayPageVC+WebBridge.swift: apply header directives and continue settling pull refresh through the existing coordinator.Views/TodayPageHeaderView.swift: render refreshing, updated, and failure states without owning refresh policy.TodayPageVC+ContentLoading.swiftandTodayPageVC+Exposure.swift: supply the correct reason at existing trigger points; leave socket/timer paths silent.- The Live Widget creation completion callback: request
live_widget_createdonly after creation succeeds.
Verification checklist
- An old Web deployment accepts a request with
reasonand still returnsrefreshResult. - An old iOS build sees no new event because it does not advertise the capability.
- User pull shows only the UIKit spinner and ends on matching result.
- A foreground return at 4 seconds does nothing; a return at 5 seconds sends
one
app_foregroundrequest and shows header feedback. - Successful Live Widget creation shows header refreshing, then updated for one second.
- Web polling runs once per minute; tab, timer, socket, and polling refreshes show no native feedback.
- Cached startup accepts a Web-generated
requestIdwith no prior native request. - Two overlapping starts ignore the first request's stale terminal result.
- Failure, cancellation, timeout, navigation, and Web process termination cannot leave either spinner stuck.