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>

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.

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

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

requestId belongs to native. Web echoes it on render events and sends exactly one refreshResult for that request.

Events

DirectionTypeMeaning
native → webtodayPage.refreshRequestedNative starts a user- or system-initiated refresh without inventing extra refresh modes. Native creates a fresh requestId and sends it to Web; Web reloads the current embed URL with that requestId, then echoes it on the render events and final refreshResult.
native → webtodayPage.scrollToSectionNative asks Web to bring a rendered Today section into view and optionally flash a short highlight. The command targets batchId, because each rendered batch owns one section. Missing or not-yet-rendered sections are ignored.
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. For a non-empty feed, this is the earliest point where Native can remove the initial loading UI.
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
}

When Web receives this event, it reloads the current embed URL with the same requestId in the query. Native may do the same reload itself instead of dispatching the event. In both cases, the next Web boot answers with refreshResult for that requestId.

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 batch does not exist, is stale, or has not rendered yet, Web silently ignores the command. Native can resend the same batchId later; Web treats each native event as a fresh command.

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.firstBatchRendered

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

This is the loading gate for non-empty feeds. Web emits it after the first contiguous widget batch has committed to the DOM and one animation frame has passed. Native should hide the initial skeleton here.

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
  status: 'succeeded' | 'failed'
  terminalEvent: 'firstBatchRendered' | 'allBatchesRendered' | 'loadFailed'
  elapsedMs?: number
  error?: string
}

refreshResult answers refreshRequested or a hard reload with ?requestId=.... Native stops pull-to-refresh on this event.

Relationship:

  1. Native creates requestId.
  2. Native either dispatches todayPage.refreshRequested, or hard-reloads the URL with requestId.
  3. Web renders.
  4. Web emits one terminal refreshResult for that requestId.

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 a refreshResult whose requestId is not the current refresh request.

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.

Implementation Checklist

Native:

  1. Load /embed/feeds/v1 for Today Page V2.
  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 and pass it to Web.
  6. Hide initial loading on firstBatchRendered, or on allBatchesRendered(empty: true) for an empty feed.
  7. Stop pull-to-refresh on 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.

Web source files:

  • apps/web/src/canvas-boot/boot.ts
  • apps/web/src/canvas-boot/feed-canvas.tsx
  • apps/web/src/canvas-boot/today-page-lifecycle.ts
  • apps/web/src/canvas-boot/today-page-refresh-request.ts
  • apps/web/src/app/embed/_feed-shell.tsx

On this page