Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions mesop/commands/navigate.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,23 @@ def navigate(
url: str,
*,
query_params: dict[str, str | Sequence[str]] | QueryParams | None = None,
open_in_new_tab: bool = False,
) -> None:
"""
Navigates to the given URL.

The URL can be an absolute URL (e.g., "http://example.com/page") or a root-relative URL
(e.g., "/page").

Document-relative URLs (e.g., "page" or "./page") are not supported.

Query parameters should be passed using the `query_params` argument. If passed in the URL directly,
they will be removed and a warning will be issued.

Args:
url: The URL to navigate to.
query_params: A dictionary of query parameters to include in the URL, or `me.query_params`. If not provided, all current query parameters will be removed.
query_params: A dictionary of query parameters to include in the URL, or `me.query_params`. If not provided and `open_in_new_tab` is False, all current query parameters will be removed. When `open_in_new_tab` is True, the current page's query parameters are preserved unless explicitly overridden.
open_in_new_tab: Whether to open the URL in a new browser tab. Defaults to False.
"""
cleaned_url = remove_url_query_param(url)
if url != cleaned_url:
Expand All @@ -32,5 +42,8 @@ def navigate(

# Clear the query params because the query params will
# either be replaced with the new query_params or emptied (in server.py).
runtime().context().query_params().clear()
runtime().context().navigate(cleaned_url, query_params)
# Don't clear query params when opening in a new tab, as we want to preserve
# the current page's state.
if not open_in_new_tab:
runtime().context().query_params().clear()
runtime().context().navigate(cleaned_url, query_params, open_in_new_tab)
3 changes: 3 additions & 0 deletions mesop/examples/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
from mesop.examples import named_nested_slot as named_nested_slot
from mesop.examples import navigate_absolute as navigate_absolute
from mesop.examples import navigate_advanced as navigate_advanced
from mesop.examples import (
navigate_new_tab as navigate_new_tab,
)
from mesop.examples import nested as nested
from mesop.examples import on_load as on_load
from mesop.examples import on_load_generator as on_load_generator
Expand Down
74 changes: 74 additions & 0 deletions mesop/examples/navigate_new_tab.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import mesop as me


@me.page(path="/navigate_new_tab/about")
def about():
me.text("About Page", type="headline-4")
me.text(
"This is the about page. Use the buttons below to navigate to different pages, "
"some of which will open in a new tab."
)


@me.page(path="/navigate_new_tab")
def page():
me.text("Navigate in New Tab Examples", type="headline-4")
me.divider()

me.text("Open internal pages in new tab:", type="headline-6")
me.button(
"Open /navigate_new_tab/about in new tab",
on_click=navigate_about_new_tab,
)

me.divider()

me.text("Open full website URLs in new tab:", type="headline-6")
me.button("Open example.com in new tab", on_click=navigate_example_new_tab)

me.divider()

me.text("Open with query params in new tab:", type="headline-6")
me.button("Open with query params", on_click=navigate_with_params_new_tab)

me.divider()

me.text("Open with url query params in new tab:", type="headline-6")
me.button(
"Open with url query params", on_click=navigate_with_url_params_new_tab
)

me.divider()

me.text("Traditional navigation (same tab):", type="headline-6")
me.button(
"Navigate to /navigate_new_tab/about (same tab)",
on_click=navigate_same_tab,
)


def navigate_about_new_tab(e: me.ClickEvent):
me.navigate("/navigate_new_tab/about", open_in_new_tab=True)


def navigate_example_new_tab(e: me.ClickEvent):
me.navigate("https://example.com", open_in_new_tab=True)


def navigate_with_params_new_tab(e: me.ClickEvent):
me.navigate(
"/navigate_new_tab/about",
query_params={"foo": "bar", "baz": "qux"},
open_in_new_tab=True,
)


def navigate_with_url_params_new_tab(e: me.ClickEvent):
me.navigate(
"/navigate_new_tab/about?foo=bar&baz=qux",
open_in_new_tab=True,
)


def navigate_same_tab(e: me.ClickEvent):
me.navigate("/navigate_new_tab/about")
2 changes: 1 addition & 1 deletion mesop/protos/ui.proto
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,8 @@ message Command {
message NavigateCommand {
// absolute route path, e.g. "/foo/bar"
optional string url = 1;

repeated QueryParam query_params = 2;
optional bool open_in_new_tab = 3;
}

message UpdateQueryParam {
Expand Down
9 changes: 7 additions & 2 deletions mesop/runtime/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,10 @@ def clear_commands(self) -> None:
self._commands = []

def navigate(
self, url: str, query_params: dict[str, str | Sequence[str]] | None = None
self,
url: str,
query_params: dict[str, str | Sequence[str]] | None = None,
open_in_new_tab: bool = False,
) -> None:
query_param_protos = None
if query_params is not None:
Expand All @@ -203,7 +206,9 @@ def navigate(
self._commands.append(
pb.Command(
navigate=pb.NavigateCommand(
url=full_url, query_params=query_param_protos
url=full_url,
query_params=query_param_protos,
open_in_new_tab=open_in_new_tab,
)
)
)
Expand Down
4 changes: 4 additions & 0 deletions mesop/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,10 @@ def generate_data(ui_request: pb.UiRequest) -> Generator[str, None, None]:
)
for command in runtime().context().commands():
if command.HasField("navigate"):
# If opening in a new tab, don't navigate the current page
# Just render the current page and let the frontend handle opening the new tab
if command.navigate.open_in_new_tab:
break
runtime().context().initialize_query_params(
command.navigate.query_params
)
Expand Down
107 changes: 107 additions & 0 deletions mesop/tests/e2e/navigate_new_tab_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {test, expect} from '@playwright/test';

test('navigate relative URL in new tab', async ({page, context}) => {
await page.goto('/navigate_new_tab');

// Set up listener for new page before clicking
const pagePromise = context.waitForEvent('page');

// Click button to open in new tab
await page
.getByRole('button', {name: 'Open /navigate_new_tab/about in new tab'})
.click();

// Wait for new page and verify URL
const newPage = await pagePromise;
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/.*\/navigate_new_tab\/about/);

// Verify original page hasn't changed
await expect(page).toHaveURL(/.*\/navigate_new_tab/);

// Clean up
await newPage.close();
});

test('navigate absolute URL in new tab', async ({page, context}) => {
await page.goto('/navigate_new_tab');

// Set up listener for new page before clicking
const pagePromise = context.waitForEvent('page');

// Click button to open in new tab
await page.getByRole('button', {name: 'Open example.com in new tab'}).click();

// Wait for new page and verify URL
const newPage = await pagePromise;
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/https:\/\/(www\.)?example\.com/);

// Verify original page hasn't changed
await expect(page).toHaveURL(/.*\/navigate_new_tab/);

// Clean up
await newPage.close();
});

test('navigate with query params in new tab', async ({page, context}) => {
await page.goto('/navigate_new_tab');

// Set up listener for new page before clicking
const pagePromise = context.waitForEvent('page');

// Click button to open with query params in new tab
await page.getByRole('button', {name: 'Open with query params'}).click();

// Wait for new page and verify URL has query params
const newPage = await pagePromise;
await newPage.waitForLoadState();
const url = new URL(newPage.url());
expect(url.pathname).toContain('/navigate_new_tab/about');
expect(url.searchParams.get('foo')).toBe('bar');
expect(url.searchParams.get('baz')).toBe('qux');

// Verify original page hasn't changed
await expect(page).toHaveURL(/.*\/navigate_new_tab/);

// Clean up
await newPage.close();
});

test('navigate with url query params in new tab', async ({page, context}) => {
await page.goto('/navigate_new_tab');

// Set up listener for new page before clicking
const pagePromise = context.waitForEvent('page');

// Click button to open with url query params in new tab
await page.getByRole('button', {name: 'Open with url query params'}).click();

// Wait for new page and verify URL has query params
const newPage = await pagePromise;
await newPage.waitForLoadState();
const url = new URL(newPage.url());
expect(url.pathname).toContain('/navigate_new_tab/about');
expect(url.searchParams.get('foo')).toBeNull();
expect(url.searchParams.get('baz')).toBeNull();

// Verify original page hasn't changed
await expect(page).toHaveURL(/.*\/navigate_new_tab/);

// Clean up
await newPage.close();
});

test('traditional same tab navigation still works', async ({page}) => {
await page.goto('/navigate_new_tab');

// Click button to navigate in same tab
await page
.getByRole('button', {
name: 'Navigate to /navigate_new_tab/about (same tab)',
})
.click();

// Verify navigation happened in same tab
await expect(page).toHaveURL(/.*\/navigate_new_tab\/about/);
});
38 changes: 38 additions & 0 deletions mesop/web/src/shell/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,44 @@ export class Shell {
onCommand: async (command) => {
if (command.hasNavigate()) {
const url = command.getNavigate()!.getUrl()!;
const openInNewTab = command.getNavigate()!.getOpenInNewTab();

if (openInNewTab) {
// When opening in a new tab, we need to handle both absolute and root-relative URLs
// Only http/https URLs are allowed; document-relative URLs are not supported.
let absoluteUrl = url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
// Reject non-root-relative URLs (e.g., "javascript:", "data:", or other schemes)
if (!url.startsWith('/')) {
console.warn(
'Refusing to open potentially unsafe URL in a new tab:',
url,
);
return;
}
if (url.startsWith('/')) {
// Convert root-relative URL to absolute URL
absoluteUrl = window.location.origin + url;
} else {
// For non-http(s), non-root-relative URLs, use the URL as-is
absoluteUrl = url;
}
}
// Final safety check: only allow http/https absolute URLs
if (
!absoluteUrl.startsWith('http://') &&
!absoluteUrl.startsWith('https://')
) {
console.warn(
'Refusing to open non-http(s) URL in a new tab:',
absoluteUrl,
);
return;
}
window.open(absoluteUrl, '_blank', 'noopener,noreferrer');
// Return immediately to prevent any other navigation logic from executing
return;
}
if (url.startsWith('http://') || url.startsWith('https://')) {
window.location.href = url;
} else {
Expand Down
Loading