Today WebView Bridge
Contract

Life Cycle Events

Common container-level lifecycle events for visibility, loading, and host state changes.

Life cycle events are container-level: their subject is the WebView and the host chrome drawn around it, never the app rendered inside it. They are not app-specific Today Page events, and any embedded surface can use them unchanged.

Most are native → web notifications. One, webView.loadingPresentation, travels web → native over the same postMessage channel the app-specific events use, because the host's loading overlay is container-level but only Web can see whether the page underneath it has anything on screen yet.

Native dispatches the inbound ones as DOM events:

window.dispatchEvent(
  new CustomEvent('todayWebViewBridge:nativeEvent', {
    detail: { type: 'webView.visibilityChanged', schemaVersion: 1, visible: true },
  }),
)

Web subscribes through the SDK:

const unsubscribe = bridge.onNativeEvent((event) => {
  if (event.type === 'webView.visibilityChanged') {
    // event.visible is the native-visible state
  }
})

onNativeEvent returns an unsubscribe function. Web code MUST ignore unknown native event types so native clients can add new lifecycle events safely.

Native → Web

TypeMeaning
webView.visibilityWillChangeNative is about to change whether this WebView is user-visible. Use this as an early signal to pause expensive work, stop impression windows, or prepare for visibility-dependent UI updates before the final visible state is committed.
webView.visibilityChangedNative has committed the WebView's visible state. visible: true means the page is now eligible for user-visible rendering, impressions, and refresh UI; visible: false means those behaviors should pause or close.
webView.loadStartedNative has started a new WebView load. When present, requestId identifies the native-owned load or refresh request that Web should echo on app-specific lifecycle events such as Today Page V2 render events.

Type Shape

type NativeLifecycleEvent =
  | { type: 'webView.visibilityWillChange'; schemaVersion: 1; visible: boolean }
  | { type: 'webView.visibilityChanged'; schemaVersion: 1; visible: boolean }
  | { type: 'webView.loadStarted'; schemaVersion: 1; requestId?: string }

Web → Native

webView.loadingPresentation

interface WebViewLoadingPresentationEvent {
  type: 'webView.loadingPresentation'
  schemaVersion: 1
  owner: 'native' | 'web'
  requestId?: string
  reason?: string
}

This is the loading gate for a host's own full-screen loading UI. Native's rule is the whole of it: while owner is 'native', keep your loading UI; the first time owner is 'web', remove it and do not bring it back for this load.

owner is the only field to branch on. An owner value this build does not recognise must drop the whole event rather than fall back to a guess — guessing 'web' would withdraw the loader from a page that has painted nothing, which is worse than a spinner that stays up too long.

reason is why the hand-off happened. It is free-form: log it, never branch on it, so Web can add a reason without a native release. requestId, when present, is the one from the webView.loadStarted that began this load.

The Today Page's own sequence for one document load, as an illustration — a different surface may use different reasons:

ownerreasonState
nativeawaiting_dataWeb is up and has requested data, but has nothing to paint yet.
webplaceholders_visibleBatch data landed. Every card slot is at least a sized skeleton.
webcontent_visibleThe first batch rendered, or the empty state is on screen.

Ownership is monotonic per load: Web never hands the loader back mid-load, and a soft refresh does not re-emit a 'native' hand-off. Any visible Web UI qualifies: skeletons, an optional Web loading indicator, an empty state, or an error state. V3 sends the hand-off in the React commit introducing that UI, without waiting for widget completion or an extra animation frame. This does not count as render success. A failure with no Web UI instead leaves presentation to the native host.

Native must make hiding its loader and revealing the WebView mutually exclusive. In particular, a main-frame commit is not permission to show Web underneath a native loader while the loading-presentation message is still in transit.

Why this is separate from an app's render-lifecycle events: those are facts about a render timeline, and each host was deriving its own loader policy from them — the same spinner was coming down through three unrelated signals per platform. Only Web knows whether it currently has a credible placeholder on screen, so Web states the policy and Native renders it.

The payload is three fields wide on purpose. Whatever a specific surface knows about its own load — an app-level load id, how many widgets mounted, how long it took — belongs on that surface's lifecycle events; a container-level contract can only require what every embedded page has.

The event is additive and needs no capability gate. A host that does not handle it drops it and keeps its existing behavior; hiding the loader earlier is a pure improvement each platform adopts on its own schedule.

Platform Mapping

These events are platform-neutral:

  • iOS maps view-controller and tab visibility into the same webView.* events.
  • macOS maps window, tab, and WebView activity into the same event names.
  • Android maps activity / fragment / tab lifecycle into the same event names.
  • Windows maps WebView2 or Electron window activity into the same event names.
  • Linux maps Electron window activity into the same event names.

The event name is the contract. Platform-specific lifecycle names should not leak into Web code.

On this page