Use HashRouter for Capacitor file-served routes (#24)
Unit Tests / test (pull_request) Successful in 10s

Isolate BrowserRouter vs HashRouter in AppRouter so nested paths reload
cleanly from the Capacitor filesystem bundle without nginx rewrites, while
web deploy keeps BrowserRouter unchanged.
This commit is contained in:
2026-07-26 15:42:03 -05:00
parent 7f16a3dca8
commit 02f14699b9
7 changed files with 264 additions and 7 deletions
+6 -4
View File
@@ -1,5 +1,6 @@
import axios from "axios";
const Cookies = require("js-cookie");
import Cookies from "js-cookie";
import { redirectToAppPath } from "./llm-fe/platform/nativePlatform";
const baseURL = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
@@ -46,7 +47,8 @@ axiosInstance.interceptors.response.use(
error.response.status === 401 &&
originalRequest.url === baseURL + "/token/refresh/"
) {
window.location.href = "/signin/";
// Hash-safe on Capacitor (#24); plain path on web BrowserRouter.
redirectToAppPath("/signin/");
//console.log('remove the local storage here')
return Promise.reject(error);
}
@@ -83,11 +85,11 @@ axiosInstance.interceptors.response.use(
});
} else {
console.log("Refresh token is expired");
window.location.href = "/signin/";
redirectToAppPath("/signin/");
}
} else {
console.log("Refresh token not available");
window.location.href = "/signin/";
redirectToAppPath("/signin/");
}
}
return Promise.reject(error);
+3 -3
View File
@@ -2,7 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
import AppRouter from './llm-fe/platform/AppRouter';
import { AuthProvider } from './llm-fe/contexts/AuthContext';
import { AccountProvider } from './llm-fe/contexts/AccountContext';
import { WebSocketProvider } from './llm-fe/contexts/WebSocketContext';
@@ -16,7 +16,7 @@ const root = ReactDOM.createRoot(
);
root.render(
<React.StrictMode>
<BrowserRouter>
<AppRouter>
<script async defer src="https://tianji.aimloperations.com/tracker.js" data-website-id="cm7x7m52m03kbddswbswrt17y"></script>
<AuthProvider>
<AccountProvider>
@@ -33,6 +33,6 @@ root.render(
</WebSocketProvider>
</AccountProvider>
</AuthProvider>
</BrowserRouter>
</AppRouter>
</React.StrictMode>
);
@@ -0,0 +1,63 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom';
import AppRouter from './AppRouter';
import { getAppRouter, isNativePlatform } from './nativePlatform';
describe('AppRouter / getAppRouter (#24)', () => {
const originalCapacitor = window.Capacitor;
afterEach(() => {
if (originalCapacitor === undefined) {
delete window.Capacitor;
} else {
window.Capacitor = originalCapacitor;
}
});
it('uses BrowserRouter on web', () => {
delete window.Capacitor;
expect(isNativePlatform()).toBe(false);
const Router = getAppRouter();
expect(Router.name).toBe('BrowserRouter');
});
it('uses HashRouter on native Capacitor', () => {
window.Capacitor = { isNativePlatform: () => true };
expect(isNativePlatform()).toBe(true);
const Router = getAppRouter();
expect(Router.name).toBe('HashRouter');
});
it('renders nested routes under HashRouter without blank screen', () => {
window.Capacitor = { isNativePlatform: () => true };
window.location.hash = '#/account/';
render(
<AppRouter>
<Routes>
<Route path="/account/" element={<div>Account Page</div>} />
<Route path="/" element={<div>Home</div>} />
</Routes>
</AppRouter>,
);
expect(screen.getByText('Account Page')).toBeInTheDocument();
});
it('renders nested routes under BrowserRouter on web', () => {
delete window.Capacitor;
window.history.pushState({}, '', '/account/');
render(
<AppRouter>
<Routes>
<Route path="/account/" element={<div>Account Page</div>} />
<Route path="/" element={<div>Home</div>} />
</Routes>
</AppRouter>,
);
expect(screen.getByText('Account Page')).toBeInTheDocument();
});
});
+17
View File
@@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
import { getAppRouter } from './nativePlatform';
type AppRouterProps = {
children: ReactNode;
};
/**
* Single place that chooses BrowserRouter (web) vs HashRouter (Capacitor).
* Avoids sprinkling Capacitor.isNativePlatform() through the route tree (#24).
*/
const AppRouter = ({ children }: AppRouterProps): JSX.Element => {
const Router = getAppRouter();
return <Router>{children}</Router>;
};
export default AppRouter;
@@ -0,0 +1,65 @@
/**
* Capacitor / WebView platform helpers (#24 / #22).
* Prefer window.Capacitor so unit tests and web builds need no Capacitor package.
*/
/**
* @returns {boolean}
*/
export function isNativePlatform() {
if (typeof window === "undefined") {
return false;
}
const Cap = window.Capacitor;
if (!Cap) {
return false;
}
if (typeof Cap.isNativePlatform === "function") {
return Cap.isNativePlatform();
}
return Cap.isNative === true;
}
/**
* Absolute in-app path for full page navigations.
* Native Capacitor builds use HashRouter; web keeps BrowserRouter paths.
*
* @param {string} path e.g. "/signin/"
* @returns {string}
*/
export function appHref(path) {
const normalized = path.startsWith("/") ? path : `/${path}`;
if (isNativePlatform()) {
return `/#${normalized}`;
}
return normalized;
}
/**
* Navigate the top-level window to an in-app route (auth failure, forced logout).
* @param {string} path
*/
export function redirectToAppPath(path) {
if (typeof window === "undefined") {
return;
}
if (isNativePlatform()) {
const normalized = path.startsWith("/") ? path : `/${path}`;
window.location.hash = `#${normalized}`;
return;
}
window.location.href = appHref(path);
}
/**
* Router class for the current runtime.
* HashRouter on Capacitor so nested routes reload without a server rewrite.
* BrowserRouter on web (nginx already rewrites to index.html).
*
* @returns {typeof import('react-router-dom').HashRouter | typeof import('react-router-dom').BrowserRouter}
*/
export function getAppRouter() {
// Lazy require keeps this module usable from axios (no React import cycle in tests).
const { BrowserRouter, HashRouter } = require("react-router-dom");
return isNativePlatform() ? HashRouter : BrowserRouter;
}
@@ -0,0 +1,92 @@
import {
appHref,
isNativePlatform,
redirectToAppPath,
} from './nativePlatform';
describe('nativePlatform', () => {
const originalCapacitor = window.Capacitor;
const originalLocation = window.location;
afterEach(() => {
if (originalCapacitor === undefined) {
delete window.Capacitor;
} else {
window.Capacitor = originalCapacitor;
}
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
});
describe('isNativePlatform', () => {
it('returns false when Capacitor missing', () => {
delete window.Capacitor;
expect(isNativePlatform()).toBe(false);
});
it('uses Capacitor.isNativePlatform when present', () => {
window.Capacitor = {
isNativePlatform: () => true,
};
expect(isNativePlatform()).toBe(true);
});
it('falls back to Capacitor.isNative', () => {
window.Capacitor = { isNative: true };
expect(isNativePlatform()).toBe(true);
});
});
describe('appHref', () => {
it('returns plain path on web', () => {
delete window.Capacitor;
expect(appHref('/signin/')).toBe('/signin/');
});
it('returns hash path on native', () => {
window.Capacitor = { isNativePlatform: () => true };
expect(appHref('/signin/')).toBe('/#/signin/');
});
});
describe('redirectToAppPath', () => {
it('sets location.href on web', () => {
delete window.Capacitor;
const hrefSetter = jest.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...originalLocation,
set href(v) {
hrefSetter(v);
},
get href() {
return '';
},
},
});
redirectToAppPath('/signin/');
expect(hrefSetter).toHaveBeenCalledWith('/signin/');
});
it('sets location.hash on native', () => {
window.Capacitor = { isNativePlatform: () => true };
let hashValue = '';
Object.defineProperty(window, 'location', {
configurable: true,
value: {
get hash() {
return hashValue;
},
set hash(v) {
hashValue = v;
},
},
});
redirectToAppPath('/signin/');
expect(hashValue).toBe('#/signin/');
});
});
});
+18
View File
@@ -1 +1,19 @@
/// <reference types="react-scripts" />
interface CapacitorBridge {
isNativePlatform?: () => boolean;
isNative?: boolean;
Plugins?: {
App?: { addListener?: (...args: unknown[]) => unknown };
Network?: { addListener?: (...args: unknown[]) => unknown };
Preferences?: {
get?: (options: { key: string }) => Promise<{ value?: string | null }>;
set?: (options: { key: string; value: string }) => Promise<void>;
remove?: (options: { key: string }) => Promise<void>;
};
};
}
interface Window {
Capacitor?: CapacitorBridge;
}