Make routing work from a file-served Capacitor bundle (#24) (#27)
Unit Tests / test (push) Successful in 10s

## Summary
- Closes #24
- Introduce `AppRouter` + `getAppRouter()` so Capacitor uses `HashRouter` (filesystem bundle, no nginx rewrite) while web keeps `BrowserRouter`
- Isolate platform checks in `nativePlatform` (no `Capacitor.isNativePlatform()` sprinkled through the route tree)
- Auth forced redirects (`axiosApi` → `/signin/`) use hash-safe `redirectToAppPath` on native
- Leave CRA `homepage` / absolute `PUBLIC_URL` asset paths unchanged so `/var/www/{env}.chat.aimloperations/html` deploy stays intact; HashRouter keeps document URL at the bundle root so assets resolve

## Test plan
- [x] Unit tests: `AppRouter` + `nativePlatform` (`npm run test:ci`)
- [ ] Capacitor build: open nested route (`/#/account/`), force WebView reload — no blank/404
- [ ] Browser back/forward + Android hardware back with HashRouter
- [ ] Web deploy smoke: deep link reload at `/account/` still works via nginx rewrite
- [ ] Confirm login redirects after 401 still land on sign-in (web path + native hash)Reviewed-on: #27
This commit was merged in pull request #27.
This commit is contained in:
2026-07-26 13:46:41 -07:00
parent 7f16a3dca8
commit 0fc8739d26
7 changed files with 264 additions and 7 deletions
+6 -4
View File
@@ -1,5 +1,6 @@
import axios from "axios"; 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; const baseURL = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
@@ -46,7 +47,8 @@ axiosInstance.interceptors.response.use(
error.response.status === 401 && error.response.status === 401 &&
originalRequest.url === baseURL + "/token/refresh/" 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') //console.log('remove the local storage here')
return Promise.reject(error); return Promise.reject(error);
} }
@@ -83,11 +85,11 @@ axiosInstance.interceptors.response.use(
}); });
} else { } else {
console.log("Refresh token is expired"); console.log("Refresh token is expired");
window.location.href = "/signin/"; redirectToAppPath("/signin/");
} }
} else { } else {
console.log("Refresh token not available"); console.log("Refresh token not available");
window.location.href = "/signin/"; redirectToAppPath("/signin/");
} }
} }
return Promise.reject(error); return Promise.reject(error);
+3 -3
View File
@@ -2,7 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import App from './App'; 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 { AuthProvider } from './llm-fe/contexts/AuthContext';
import { AccountProvider } from './llm-fe/contexts/AccountContext'; import { AccountProvider } from './llm-fe/contexts/AccountContext';
import { WebSocketProvider } from './llm-fe/contexts/WebSocketContext'; import { WebSocketProvider } from './llm-fe/contexts/WebSocketContext';
@@ -16,7 +16,7 @@ const root = ReactDOM.createRoot(
); );
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <AppRouter>
<script async defer src="https://tianji.aimloperations.com/tracker.js" data-website-id="cm7x7m52m03kbddswbswrt17y"></script> <script async defer src="https://tianji.aimloperations.com/tracker.js" data-website-id="cm7x7m52m03kbddswbswrt17y"></script>
<AuthProvider> <AuthProvider>
<AccountProvider> <AccountProvider>
@@ -33,6 +33,6 @@ root.render(
</WebSocketProvider> </WebSocketProvider>
</AccountProvider> </AccountProvider>
</AuthProvider> </AuthProvider>
</BrowserRouter> </AppRouter>
</React.StrictMode> </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" /> /// <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;
}