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
| Direction | Type | Meaning |
|---|---|---|
| native → web | todayPage.refreshRequested | Native 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 → web | todayPage.scrollToSection | Native 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 → native | todayPage.refreshStarted | Capability-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 → native | webView.loadingPresentation | Which 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 → native | todayPage.firstBatchRendered | The 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 → native | todayPage.allBatchesRendered | Every 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 → native | todayPage.loadFailed | The 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 → native | todayPage.refreshResult | The 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, andpage_end. - Web reports Today Page/Card render results, card impressions, and card clicks
by sending
trackmessages through the bridge. Native enriches those events with its ownuid,did,sid,client_plat, andctxbefore 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_submitlater, 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:
reason | Presentation owner | Web behavior |
|---|---|---|
user_pull | Native pull-to-refresh spinner | No refreshStarted; refreshResult ends the spinner. |
app_foreground | Native Today header | Emit refreshStarted(presentation: 'header'), then refreshResult. |
live_widget_created | Native Today header | Emit refreshStarted(presentation: 'header'), then refreshResult. |
tab_activated | None | Silent revalidation; no refreshStarted. |
| absent | Existing legacy caller behavior | Soft 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:
- Native creates
requestIdand dispatchestodayPage.refreshRequested, or Web creates it for a cached-start background revalidation. - Web optionally emits
refreshStartedwhen the feedback belongs in the native header and Native advertised the capability. - Web revalidates or renders.
- Web emits one terminal
refreshResultfor thatrequestId. - 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 anotherrefreshResult.
Success rules:
- Non-empty feed:
refreshResult(status: 'succeeded')followsfirstBatchRendered. - Empty feed:
refreshResult(status: 'succeeded')followsallBatchesRendered(empty: true). - Pre-visible failure:
refreshResult(status: 'failed')followsloadFailed.
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.jsimmediately, 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 andwebView.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
firstBatchRenderedor 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.
Card Action Deeplinks
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:
- Load
/embed/feeds/v1for Today Page V2 and include the current accountuidso Web can select the correct offline cache. - Implement the common bridge channel and resolve known/unknown
todayPage.*events withundefined. - Report
app_sess_start,page_view, andpage_endfrom native for the embed page. - Forward Web
trackmessages to the native analytics pipeline so native base fields are used. - On native refresh, create
requestId, add the applicablereason, and pass both to Web. - Hide initial loading the first time
webView.loadingPresentationreportsowner: 'web', and do not restore it for that load. Do not branch onreason. Hosts that have not adopted the event yet hide onfirstBatchRendered, or onallBatchesRendered(empty: true)for an empty feed. - Keep the UIKit pull-to-refresh spinner for
user_pull; stop it on the matchingrefreshResult. - Use
todayPage.scrollToSectionwhen native needs to focus a specific Today section and show a short visual highlight. - Use common
webView.visibilityWillChange/webView.visibilityChangedfor visibility and impression gating. - Route trusted
today://chat...action links withmsg_trigger_ele_idinto the nextchat_query_submitwhen chat is prefilled. - On
/embed/feeds/v1/edit, advertisenative_dialog.v1and present Embed's destructive delete confirmation; return the selected action and leave deletion and revalidation to Embed. - On
/embed/feeds/v1, advertiselive_widget_creation.v1when the existing native Live Widget creation sheet is available; routeliveWidgetCreation.presentto that sheet and return an immediate{ presented }acknowledgement. - Advertise
today_page_revalidate_feedback.v1only after the native header renderer handlesrefreshStartedandrefreshResultcorrelation.
Shared and Embed source files:
packages/today-widget-runtime/src/client/feed/native/today-page-native-event.tspackages/today-widget-runtime/src/client/feed/native/use-native-commands.tspackages/today-widget-runtime/src/client/feed/native/use-native-refresh-feedback.tspackages/today-widget-runtime/src/client/feed/native/today-page-lifecycle.tspackages/today-widget-runtime/src/client/today-page/load-batches-cache.tsapps/embed/src/app/embed/embed-feed-mount.tsx