Today WebView Bridge
Native Reference

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.refreshRequested with a reason.
  • 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 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 triggerreasonVisible UI
TodayPageWebView.onPullToRefresh / TodayEmptyView.onPullToRefreshuser_pullExisting pull spinner only
Return after at least 5 seconds outside the foregroundapp_foregroundHeader, when Web accepts the refresh
Successful Live Widget creation completionlive_widget_createdHeader, when Web accepts the refresh
Today tab activationtab_activatedNone
Socket push, timer, ordinary maintenanceDo not create a visible reasonNone

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) ─────────> hidden

Implementation requirements:

  • A new refreshStarted replaces the active header requestId and cancels any pending one-second hide task.
  • Ignore a stale refreshResult whose requestId does not match the active header request.
  • succeeded shows the localized updated state for one second, then hides.
  • failed may show a localized failure state for one second; it must not claim the content was updated.
  • cancelled and unsupported hide 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 serialize TodayPageRefreshReason.
  • TodayPageRefreshCoordinator.swift: retain the reason with each request while preserving UUID correlation and the pull watchdog.
  • TodayPageBridgeEvent.swift: parse refreshStarted and 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.swift and TodayPageVC+Exposure.swift: supply the correct reason at existing trigger points; leave socket/timer paths silent.
  • The Live Widget creation completion callback: request live_widget_created only after creation succeeds.

Verification checklist

  1. An old Web deployment accepts a request with reason and still returns refreshResult.
  2. An old iOS build sees no new event because it does not advertise the capability.
  3. User pull shows only the UIKit spinner and ends on matching result.
  4. A foreground return at 4 seconds does nothing; a return at 5 seconds sends one app_foreground request and shows header feedback.
  5. Successful Live Widget creation shows header refreshing, then updated for one second.
  6. Web polling runs once per minute; tab, timer, socket, and polling refreshes show no native feedback.
  7. Cached startup accepts a Web-generated requestId with no prior native request.
  8. Two overlapping starts ignore the first request's stale terminal result.
  9. Failure, cancellation, timeout, navigation, and Web process termination cannot leave either spinner stuck.

On this page