MM-67269 - Fix popout windows for subpath deployments (#35027)

* MM-67269 - Fix popout windows for subpath deployments

Popout windows were failing with 404 errors when Mattermost is served
from a subpath (e.g., https://company.com/mattermost). The popout
functions were constructing URLs without including the subpath prefix.

Changes:
- Updated popoutThread() and popoutRhsPlugin() to use getBasePath()
  helper function which includes window.basename
- Added unit tests to verify popout URLs include subpath when configured
- Follows established pattern used throughout codebase (getSiteURL,
  cookie paths, React Router)

This ensures popout windows open at the correct URL:
  /subpath/_popout/... instead of /_popout/...

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Clear mocks and set default base path in tests

---------

Co-authored-by: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Scott Bishel 2026-01-27 14:25:15 -07:00 committed by GitHub
parent eeaf9c8e3e
commit fb22f56635
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 46 additions and 2 deletions

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import DesktopApp from 'utils/desktop_api';
import {getBasePath} from 'utils/url';
import {isDesktopApp} from 'utils/user_agent';
import {FOCUS_REPLY_POST, popoutRhsPlugin, popoutThread} from './popout_windows';
@ -20,6 +21,10 @@ jest.mock('utils/user_agent', () => ({
isDesktopApp: jest.fn(),
}));
jest.mock('utils/url', () => ({
getBasePath: jest.fn(),
}));
jest.mock('./browser_popouts', () => {
const mockFn = jest.fn();
(globalThis as typeof globalThis & {mockSetupBrowserPopout: typeof mockFn}).mockSetupBrowserPopout = mockFn;
@ -33,6 +38,7 @@ jest.mock('./browser_popouts', () => {
const mockDesktopApp = DesktopApp as jest.Mocked<typeof DesktopApp>;
const mockIsDesktopApp = isDesktopApp as jest.MockedFunction<typeof isDesktopApp>;
const mockGetBasePath = getBasePath as jest.MockedFunction<typeof getBasePath>;
const getMockSetupBrowserPopout = () => {
return (globalThis as typeof globalThis & {mockSetupBrowserPopout: jest.MockedFunction<() => unknown>}).mockSetupBrowserPopout;
@ -42,6 +48,9 @@ describe('popout_windows', () => {
beforeEach(() => {
jest.clearAllMocks();
getMockSetupBrowserPopout().mockClear();
// Default: no subpath
mockGetBasePath.mockReturnValue('');
});
describe('popoutThread', () => {
@ -121,6 +130,23 @@ describe('popout_windows', () => {
expect(mockOnFocusPost).toHaveBeenCalledTimes(1);
expect(mockOnFocusPost).toHaveBeenCalledWith('post-123', '/team/pl/post-123');
});
it('should include subpath in popout URL when basename is set', async () => {
mockIsDesktopApp.mockReturnValue(false);
mockGetBasePath.mockReturnValue('/company/mattermost');
const mockListeners = {
sendToPopout: jest.fn(),
onMessageFromPopout: jest.fn(),
onClosePopout: jest.fn(),
};
getMockSetupBrowserPopout().mockReturnValue(mockListeners);
await popoutThread('Thread - {channelName} - {teamName} - {serverName}', 'thread-123', 'test-team', mockOnFocusPost);
expect(getMockSetupBrowserPopout()).toHaveBeenCalledWith(
'/company/mattermost/_popout/thread/test-team/thread-123',
);
});
});
describe('popoutRhsPlugin', () => {
@ -173,6 +199,23 @@ describe('popout_windows', () => {
expect(result).toEqual(mockListeners);
});
it('should include subpath in popout URL when basename is set', async () => {
mockIsDesktopApp.mockReturnValue(false);
mockGetBasePath.mockReturnValue('/company/mattermost');
const mockListeners = {
sendToPopout: jest.fn(),
onMessageFromPopout: jest.fn(),
onClosePopout: jest.fn(),
};
getMockSetupBrowserPopout().mockReturnValue(mockListeners);
await popoutRhsPlugin('{pluginDisplayName} - {serverName}', 'test-plugin-id', 'test-team', 'test-channel');
expect(getMockSetupBrowserPopout()).toHaveBeenCalledWith(
'/company/mattermost/_popout/rhs/test-team/test-channel/plugin/test-plugin-id',
);
});
});
});

View file

@ -6,6 +6,7 @@ import type {PopoutViewProps} from '@mattermost/desktop-api';
import {Client4} from 'mattermost-redux/client';
import DesktopApp from 'utils/desktop_api';
import {getBasePath} from 'utils/url';
import {isDesktopApp} from 'utils/user_agent';
import BrowserPopouts from './browser_popouts';
@ -27,7 +28,7 @@ export async function popoutThread(
onFocusPost: (postId: string, returnTo: string) => void,
) {
const popoutListeners = await popout(
`/_popout/thread/${teamName}/${threadId}`,
`${getBasePath()}/_popout/thread/${teamName}/${threadId}`,
{
isRHS: true,
titleTemplate,
@ -54,7 +55,7 @@ export async function popoutRhsPlugin(
channelName: string,
) {
const listeners = await popout(
`/_popout/rhs/${teamName}/${channelName}/plugin/${pluginId}`,
`${getBasePath()}/_popout/rhs/${teamName}/${channelName}/plugin/${pluginId}`,
{
isRHS: true,
titleTemplate,