How to Track Navigation Events in a Tauri Webview: Complete Implementation Guide

You can track navigation events in a Tauri webview by injecting an initialization script that intercepts browser history API calls, then emitting custom Tauri events to communicate URL changes from the webview to your Rust backend or React frontend.

The lencx/ChatGPT application demonstrates a robust pattern for monitoring URL changes inside embedded webviews. By combining Tauri's script injection capabilities with its cross-context event system, you can capture both traditional page loads and single-page application (SPA) routing changes.

Architecture Overview

The navigation tracking system uses a three-layer approach. First, a JavaScript initialization script hooks into the browser's history API before any page content loads. Second, the script emits custom Tauri events when navigation occurs. Third, React components listen for these events and update the UI accordingly.

This decoupled architecture ensures the main webview remains unaware of the monitoring logic, while the titlebar component receives real-time updates about URL changes.

Injecting the Navigation Tracking Script

In src-tauri/src/core/constant.rs, the constant INIT_SCRIPT contains the JavaScript code injected into every webview window at creation time. This script overrides history.pushState and history.replaceState while adding listeners for popstate events to detect all forms of navigation.

// src-tauri/src/core/constant.rs
pub static INIT_SCRIPT: &str = r#"
window.addEventListener('DOMContentLoaded', function() {
    function handleUrlChange() {
        const url = window.location.href;
        if (url !== 'about:blank') {
            console.log('URL changed:', url);
            // Emit the custom Tauri event
            window.__TAURI__.webviewWindow.WebviewWindow
                .getByLabel('titlebar')
                .emit('navigation:change', { url });
        }
    }
    
    // Hook into SPA navigation
    window.addEventListener('popstate', handleUrlChange);
    
    const originalPush = history.pushState;
    history.pushState = function() {
        originalPush.apply(this, arguments);
        handleUrlChange();
    };
    
    const originalReplace = history.replaceState;
    history.replaceState = function() {
        originalReplace.apply(this, arguments);
        handleUrlChange();
    };
    
    // Trigger initial check
    handleUrlChange();
});
"#;

The script executes during the DOMContentLoaded event, ensuring it initializes after the document exists but before external scripts manipulate the history API. By storing references to the original pushState and replaceState methods, it maintains compatibility with existing web applications while adding the tracking layer.

Emitting Events to Specific Webview Windows

When navigation occurs, the script targets a specific webview window by label. In this implementation, the event is emitted to the 'titlebar' webview using Tauri's WebviewWindow.getByLabel() API.

// Inside the INIT_SCRIPT
window.__TAURI__.webviewWindow.WebviewWindow
    .getByLabel('titlebar')
    .emit('navigation:change', { url });

This targeted emission pattern allows you to route navigation data only to components that need it, rather than broadcasting to all windows. The payload contains the full URL, enabling listeners to parse hostnames, paths, or query parameters as needed.

Listening for Navigation Events in React

The React frontend consumes these events through Tauri's window API. In src/view/Titlebar.tsx, the component registers a listener during its effect lifecycle and properly cleans up on unmount to prevent memory leaks.

import { getCurrentWindow } from '@tauri-apps/api/window';
import { useEffect, useState } from 'react';

export default function Titlebar() {
  const [url, setUrl] = useState('');
  const [hostname, setHostname] = useState('');

  useEffect(() => {
    // Register the listener for navigation events
    const unlisten = getCurrentWindow().listen('navigation:change', (event) => {
      const { url } = event.payload;
      setUrl(url);
      try {
        setHostname(new URL(url).hostname);
      } catch {
        setHostname(url);
      }
    });

    // Cleanup function removes the listener
    return () => {
      unlisten && unlisten();
    };
  }, []);

  return (
    <div className="titlebar">
      <span className="hostname">{hostname}</span>
      <span className="url">{url}</span>
    </div>
  );
}

The listen method returns an unlisten function that must be invoked when the component unmounts. This pattern ensures that navigation tracking doesn't accumulate ghost listeners during hot reloading or route transitions within your Tauri application.

Extending the Pattern for Custom Analytics

You can generalize this approach to track additional navigation metadata or emit different event types. For example, to track page load times alongside URL changes, modify the handleUrlChange function to include performance metrics:

// Extended INIT_SCRIPT snippet
function handleUrlChange() {
    const url = window.location.href;
    const loadTime = performance.now();
    
    window.__TAURI__.webviewWindow.WebviewWindow
        .getByLabel('analytics')
        .emit('navigation:metrics', { 
            url, 
            loadTime,
            timestamp: Date.now()
        });
}

Any component or Rust command can then subscribe to these custom events using the same listen pattern, enabling comprehensive analytics tracking without modifying the underlying web content.

Summary

  • Script injection via INIT_SCRIPT in src-tauri/src/core/constant.rs intercepts browser history API calls before page scripts execute.
  • Event emission uses window.__TAURI__.webviewWindow.WebviewWindow.getByLabel() to target specific webview windows like 'titlebar'.
  • Cross-context communication relies on the custom event name navigation:change to transport URL data from the webview to the React frontend.
  • Memory management requires storing and invoking the unlisten function returned by getCurrentWindow().listen() when components unmount.
  • SPA compatibility is achieved by overriding pushState and replaceState while listening to popstate events.

Frequently Asked Questions

How do I intercept SPA navigation in Tauri?

Intercept SPA navigation by overriding the history.pushState and history.replaceState methods in an initialization script injected via Tauri's init_script configuration. Add listeners for the popstate event to capture browser back/forward navigation. As implemented in src-tauri/src/core/constant.rs, this approach captures URL changes regardless of whether they originate from traditional navigation or JavaScript framework routers.

Can I track navigation events across multiple webview windows?

Yes, you can track navigation across multiple windows by emitting events to different labels or using broadcast patterns. Instead of targeting a specific label with getByLabel('titlebar'), you can emit to all windows or maintain a registry of window labels in your Rust backend. Each window can also listen for its own navigation events by emitting to its own label and having the backend forward or process those events centrally.

How do I clean up event listeners in Tauri to prevent memory leaks?

Always store the return value of getCurrentWindow().listen() in a variable, which provides an unlisten function. Invoke this function in your component's cleanup lifecycle (React's useEffect return function or Vue's onUnmounted hook). The Titlebar.tsx implementation demonstrates this pattern by calling unlisten() when the component unmounts, ensuring the event handler is properly detached from the Tauri event bus.

What is the difference between using emit and invoke for navigation tracking?

Use emit for unidirectional communication from the webview to the frontend or backend, which is ideal for navigation tracking where the webview broadcasts state changes without expecting a return value. Use invoke when you need a request-response pattern or need to execute Rust commands that return data to the webview. For navigation events, emit is more appropriate because the webview should not block waiting for the UI to acknowledge a URL change.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →