Today WebView Bridge
Contract

Today Page V2

Minimal native-facing render and refresh contract for the Today Page V2 WebView surface.

Today Page V2 uses the common todayWebViewBridge transport for web → native events. Native → web visibility lifecycle is common bridge behavior; see Life Cycle Events.

Unknown todayPage.* events still follow the bridge rule: resolve undefined, never reject.

Surface

Native loads:

/embed/feeds/v1?language=<language>&uid=<current-user-id>

language accepts en, zh, zh-Hant, and ja. Common regional aliases such as zh-CN, zh-TW, zh-HK, and ja-JP are normalized by Web; missing or unsupported values fall back to English.

uid is the native host's stable current-account id. Web uses it only as an identity hint to select user-scoped IndexedDB snapshots before the network is available; it is not authorization. The HttpOnly embed session still authenticates every BFF request, and a user id verified from that online session replaces a stale hint. Native must replace uid on account switch and must not log the full embed URL.

When a native refresh performs a hard WebView reload, native adds or replaces requestId:

/embed/feeds/v1?requestId=<request-id>

For native-originated refreshes, requestId belongs to native. Web echoes it on render events and sends exactly one refreshResult for that request. Web may also mint a requestId when cached content is already visible and its initial background revalidation needs native header feedback.

Events

DirectionTypeMeaning
native → webtodayPage.refreshRequestedNative supplies a refresh fact and a fresh requestId. Web owns whether that reason uses pull-to-refresh, native header feedback, or no visible feedback, then answers with one terminal refreshResult.
native → webtodayPage.scrollToSectionNative asks Web to bring a Today section into view and optionally flash a short highlight. The command targets batchId, because each rendered batch owns one section. If the client does not have that batch, Web holds the command pending through one revalidation before resolving it.
web → nativetodayPage.refreshStartedCapability-gated instruction to show native header refresh feedback for requestId. It can answer a native request or a Web-originated cache revalidation. Native is a renderer of this state, not the refresh-policy owner.
web → nativewebView.loadingPresentationWhich side paints the loading state. A container-level event, not a Today Page one. Web hands it over the moment it has a placeholder of its own on screen, which is well before any widget has rendered. This, not a render-lifecycle event, is what removes the initial native loading UI.
web → nativetodayPage.firstBatchRenderedThe first contiguous widget batch has finished fetching its widgets, React has committed those widgets to the DOM, and one animation frame has passed. A render fact and a telemetry point; hosts that handle loadingPresentation have already hidden their loader by here.
web → nativetodayPage.allBatchesRenderedEvery initial batch in the page has reached a terminal render state. status: 'succeeded' means all widgets rendered; status: 'partial' means at least one widget fell back to a placeholder, but the page is still usable and visible.
web → nativetodayPage.loadFailedThe Web surface failed before any batch became visible. Native should treat this as a pre-content failure for the current load or refresh request; widget-level failures after content is visible are reported through partial rendering, not this event.
web → nativetodayPage.refreshResultThe terminal answer for the current native requestId. Native should stop pull-to-refresh or refresh spinners here, and should ignore results whose requestId is no longer current. Success usually follows firstBatchRendered; empty feeds complete on allBatchesRendered.

Analytics Ownership

For /embed/feeds/v1 inside a native WebView:

  • Native reports app_sess_start, page_view, and page_end.
  • Web reports Today Page/Card render results, card impressions, and card clicks by sending track messages through the bridge. Native enriches those events with its own uid, did, sid, client_plat, and ctx before forwarding them to PostHog.
  • For chat prefill actions, Web appends Today attribution query parameters to the action link itself before navigation. Native routes the trusted in-app deeplink normally and reports chat_query_submit later, when the user actually sends.

If /embed/feeds/v1 is opened in a normal browser with no native bridge, Web uses posthog-js directly and owns the full event set for that browser session.

Native → Web

Native dispatches native → web events through the common DOM event described in Life Cycle Events.

todayPage.refreshRequested

interface TodayPageRefreshRequestedEvent {
  type: 'todayPage.refreshRequested'
  schemaVersion: 1
  requestId: string
  reason?: 'user_pull' | 'app_foreground' | 'live_widget_created' | 'tab_activated'
}

This is an additive extension of the existing soft-revalidation command. A missing reason keeps the legacy behavior. An unknown reason is ignored while the otherwise valid refresh request is still accepted, so a newer native app does not strand an older Web deployment.

Web maps native facts to presentation policy:

reasonPresentation ownerWeb behavior
user_pullNative pull-to-refresh spinnerNo refreshStarted; refreshResult ends the spinner.
app_foregroundNative Today headerEmit refreshStarted(presentation: 'header'), then refreshResult.
live_widget_createdNative Today headerEmit refreshStarted(presentation: 'header'), then refreshResult.
tab_activatedNoneSilent revalidation; no refreshStarted.
absentExisting legacy caller behaviorSoft revalidation and refreshResult; no newly introduced visual feedback.

The current iOS policy sends app_foreground only when the app spent at least 5 seconds away from the foreground. Initial activation and shorter app switches do not request this revalidation.

All native requests, including silent ones, join an already active fetch and wait for its outcome instead of returning cancelled or a no-op success. Native may still hard-reload the current URL with ?requestId=...; that legacy path is answered by the next document's lifecycle events.

todayPage.scrollToSection

interface TodayPageScrollToSectionEvent {
  type: 'todayPage.scrollToSection'
  schemaVersion: 1
  batchId: string
  behavior?: 'auto' | 'smooth'
  highlight?: boolean
}

Web looks for the rendered section with data-batch-id === batchId, calls scrollIntoView({ block: 'start', behavior }), and briefly highlights that section when highlight !== false.

Defaults:

  • behavior: 'smooth'
  • highlight: true

If the client already has the batch, Web forwards the command immediately. A batch that is loaded but not revealed yet remains pending in the feed until its section can render.

If the client does not have the batch, Web holds the command pending and starts one silent revalidation, joining an in-flight revalidation when one already exists. After that revalidation settles, Web forwards the original command if the batch is now available. Otherwise Web cancels the command and logs [today-feed] batch id "<batchId>" is still not found after revalidating to the Web console. This command still has no native reply event.

Web → Native

Web sends these events with:

bridge.emit(type, payload)

The native host parses known todayPage.* events, routes them to the Today Page state owner, then resolves undefined. Unknown fields should be ignored.

todayPage.refreshStarted

interface TodayPageRefreshStartedEvent {
  type: 'todayPage.refreshStarted'
  schemaVersion: 1
  requestId: string
  presentation: 'header'
}

Web emits this event only when Native advertises today_page_revalidate_feedback.v1. Native shows the Today header's refreshing state and correlates its terminal transition with requestId.

This event may be Web-originated. When the page boots from cached data, Web can display that data immediately, mint a requestId, and emit refreshStarted for the already-running background revalidation. Native must therefore accept a valid refreshStarted even when it did not previously send a matching refreshRequested.

Web's fallback polling runs once per minute. Polling, tab activation, and other silent revalidations do not emit this event. User pull-to-refresh also does not emit it because UIKit already owns that spinner.

webView.loadingPresentation

The loading gate is not a Today Page event. Its subject is the host's own loading overlay over the WebView, so it lives with the container-level events — see webView.loadingPresentation for the payload and the native rule.

What is Today-Page-specific is the vocabulary this surface puts in reason (awaiting_data → placeholders_visible → content_visible) and the fact that this event, not firstBatchRendered, is what removes the initial native loading UI. It uses the same division of labor refreshStarted already uses for refresh feedback: Web states the policy, Native renders it.

todayPage.firstBatchRendered

interface TodayPageFirstBatchRenderedEvent {
  type: 'todayPage.firstBatchRendered'
  schemaVersion: 1
  requestId?: string
  batchId: string
  elapsedMs: number
}

Web emits it after the first contiguous widget batch has committed to the DOM and one animation frame has passed. It is a render fact and a refreshResult trigger, not the loading gate: a host handling loadingPresentation hid its loader earlier, and one that does not may still use this event as its gate.

Empty feeds do not emit firstBatchRendered; they complete through allBatchesRendered.

todayPage.allBatchesRendered

interface TodayPageAllBatchesRenderedEvent {
  type: 'todayPage.allBatchesRendered'
  schemaVersion: 1
  requestId?: string
  status: 'succeeded' | 'partial'
  empty: boolean
  elapsedMs: number
}

This is the completion/telemetry point. Native should not wait for this event to hide loading when firstBatchRendered already fired.

status: 'partial' means at least one widget rendered a fallback placeholder, but the page is still user-visible.

todayPage.loadFailed

interface TodayPageLoadFailedEvent {
  type: 'todayPage.loadFailed'
  schemaVersion: 1
  requestId?: string
  error: string
  elapsedMs: number
}

Web emits this only if the page fails before any batch becomes visible. Widget fallbacks after content is visible are not loadFailed.

todayPage.refreshResult

interface TodayPageRefreshResultEvent {
  type: 'todayPage.refreshResult'
  schemaVersion: 1
  requestId: string
  loadId?: string
  mode: 'hard' | 'soft'
  status: 'succeeded' | 'failed' | 'cancelled' | 'unsupported'
  terminalEvent?: 'firstBatchVisible' | 'firstBatchRendered' | 'allBatchesRendered' | 'loadFailed'
  elapsedMs?: number
  error?: {
    stage: 'loadBatches' | 'mountFeedCanvas'
    status?: number
    code?: string
    message: string
  }
}

refreshResult is the common terminal answer for soft revalidation, hard reload, and capability-gated Web-originated cache revalidation. Native ends whichever UI currently owns the same requestId.

Relationship:

  1. Native creates requestId and dispatches todayPage.refreshRequested, or Web creates it for a cached-start background revalidation.
  2. Web optionally emits refreshStarted when the feedback belongs in the native header and Native advertised the capability.
  3. Web revalidates or renders.
  4. Web emits one terminal refreshResult for that requestId.
  5. After a render-lifecycle result is emitted, Web silently enqueues every confirmed widget mount/render failure for a fresh bundle load and remount. The recovery queue has concurrency 1; it does not delay or emit another refreshResult.

Success rules:

  • Non-empty feed: refreshResult(status: 'succeeded') follows firstBatchRendered.
  • Empty feed: refreshResult(status: 'succeeded') follows allBatchesRendered(empty: true).
  • Pre-visible failure: refreshResult(status: 'failed') follows loadFailed.

Native must ignore stale results whose requestId does not match the active UI owner. Native-requested soft refreshes emit only succeeded or failed. An actual successful fetch with unchanged data is success; skipped, paused, cancelled, or superseded work is not. The legacy cancelled and unsupported values remain accepted on the wire, but iOS and Android recover as for failure.

For a soft refresh, success is sent only after the requested data revalidation completes. V3 also awaits its active card-query revalidation and a render opportunity; V2's failed-widget recovery remains a separate Web-owned queue as described above. The render-event ordering above describes initial/hard loads, not every soft refresh. On failure or no matching result within 5 seconds, iOS and Android consume the request and reload the document once. The fallback navigation does not start another soft-refresh watchdog. Navigation cancels the old request; late results cannot trigger a second reload. Overlapping native requests share the current deadline instead of postponing it.

The same update and refresh policy applies to /embed/feeds/v1 (Today V2), /embed/feeds/v3, /embed/feeds/v1/edit, and /embed/feed/v1/:batchId:

  • The common embed document provider registers /embed/sw.js immediately, including empty/edit pages with no widget bundles. Startup, native foreground, browser visibility, and reconnect share the same throttled update check.
  • A ready replacement waits while any embed client is visible. It asks every same-registration embed document to lock its hidden presentation before activation; a missing acknowledgement keeps the old worker. Hidden documents reload after replacement, with the existing source/target loop guard. A quick return stays behind the shared Web loading UI until that replacement completes. Edit uses the same visibility rule, without a separate unsaved-edit exception.
  • Native sends webView.visibilityWillChange(false) when leaving/pausing and webView.visibilityChanged(false) only after disappearing/backgrounding. The positive will-change event provides a pre-presentation update opportunity. Exposure/inactivity alone is not permission to replace a visible document.
  • Native-requested refreshes must complete real data work and report success or failure. The native 5-second deadline and one-shot document fallback are identical across native embed hosts.
  • Automatic failure recovery has one budget across document replacements, rearmed only by a successful data refresh or explicit user refresh. Unknown result statuses settle without an automatic reload; stale results cannot rearm the budget.
  • Where the host exposes manual refresh, every gesture advances a 10-second sliding window. A gesture within 10 seconds of the previous one immediately reloads the document, including the third and subsequent gestures. Automatic refreshes do not enter this window; navigation/result completion does not reset it.
  • Any Web-owned visible UI, including placeholders and errors, releases native loading presentation. This does not imply firstBatchRendered or exposure success.

Native-hosted /feeds also follows the refresh-result ordering above. Native owns the refresh indicator only through refreshResult; failed-card recovery is Web-owned and continues after the indicator has settled.

Web does not send a separate bridge message for card action clicks. On user click inside a Today card, Web reports today_card_click through track and then appends attribution directly to Today chat deeplinks before the WebView navigates:

today://chat/send?text=...&today_source=today_page_v2&today_card_id=...&today_bundle_hash=...&today_batch_id=...&today_source_trace_id=...&msg_trigger_ele_id=...

Native should route the resulting today://chat... URL as a trusted in-app deeplink. today_card_id and msg_trigger_ele_id are the stable wgt_ widget row id, while today_bundle_hash carries the content-addressed bundle hash. The row id should be carried into the next manual chat_query_submit. Editing the prefilled draft must not clear that id; sending must consume and clear it; a later card click overwrites it.

This also applies to legacy TCK cards that render a hand-authored <a href="today://chat/send?..."> instead of the SDK Action primitive. Web intercepts the composed click at the feed boundary, parses the deeplink back into today.action / chat.composer.fill, replaces any link-provided card reference with the host-owned card widgetId and summary, and then resumes the same analytics and native-navigation pipeline.

The per-card Edit affordance on /embed/feeds/v1/edit uses this same action path. Web fills text with the localized opalToday.liveWidgets.editComposerPrompt product copy and includes the card's widgetId and a non-empty summary; native must handle it exactly like an in-widget chat.composer.fill action rather than expecting a separate edit bridge message.

The per-card Delete affordance on that route uses native_dialog.v1 only to present the destructive confirmation. Web proceeds only when the terminal result is an action with actionId: 'delete'; cancel, dismissal, busy, unavailable, and bridge failure leave the widget untouched. After confirmation, Web owns the delete API call, optimistic card removal, failure rollback, and successful feed-query revalidation. Native must not delete the widget or mutate feed/cache state.

When the embedded pinned section is empty, Web shows its Add affordance only if the host advertises live_widget_creation.v1. Pressing it sends liveWidgetCreation.present; native presents the existing Live Widget creation sheet and returns only whether presentation started. Web does not open its own guided-creation modal in the embed. The Today header remains native-owned and is not rendered by the embedded document.

Implementation Checklist

Native:

  1. Load /embed/feeds/v1 for Today Page V2 and include the current account uid so Web can select the correct offline cache.
  2. Implement the common bridge channel and resolve known/unknown todayPage.* events with undefined.
  3. Report app_sess_start, page_view, and page_end from native for the embed page.
  4. Forward Web track messages to the native analytics pipeline so native base fields are used.
  5. On native refresh, create requestId, add the applicable reason, and pass both to Web.
  6. Hide initial loading the first time webView.loadingPresentation reports owner: 'web', and do not restore it for that load. Do not branch on reason. Hosts that have not adopted the event yet hide on firstBatchRendered, or on allBatchesRendered(empty: true) for an empty feed.
  7. Keep the UIKit pull-to-refresh spinner for user_pull; stop it on the matching refreshResult.
  8. Use todayPage.scrollToSection when native needs to focus a specific Today section and show a short visual highlight.
  9. Use common webView.visibilityWillChange / webView.visibilityChanged for visibility and impression gating.
  10. Route trusted today://chat... action links with msg_trigger_ele_id into the next chat_query_submit when chat is prefilled.
  11. On /embed/feeds/v1/edit, advertise native_dialog.v1 and present Embed's destructive delete confirmation; return the selected action and leave deletion and revalidation to Embed.
  12. On /embed/feeds/v1, advertise live_widget_creation.v1 when the existing native Live Widget creation sheet is available; route liveWidgetCreation.present to that sheet and return an immediate { presented } acknowledgement.
  13. Advertise today_page_revalidate_feedback.v1 only after the native header renderer handles refreshStarted and refreshResult correlation.

Shared and Embed source files:

  • packages/today-widget-runtime/src/client/feed/native/today-page-native-event.ts
  • packages/today-widget-runtime/src/client/feed/native/use-native-commands.ts
  • packages/today-widget-runtime/src/client/feed/native/use-native-refresh-feedback.ts
  • packages/today-widget-runtime/src/client/feed/native/today-page-lifecycle.ts
  • packages/today-widget-runtime/src/client/today-page/load-batches-cache.ts
  • apps/embed/src/app/embed/embed-feed-mount.tsx

On this page