Make routing work from a file-served Capacitor bundle (#24) #27
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
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