Make routing work from a file-served Capacitor bundle (#24) (#27)
Unit Tests / test (push) Successful in 10s
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:
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+18
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user