From 0fc8739d26af088b90a7241b173485cf1a4c9987 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Sun, 26 Jul 2026 13:46:41 -0700 Subject: [PATCH] Make routing work from a file-served Capacitor bundle (#24) (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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: https://git.aimloperations.com/ai_ml_operations/chat_web_app/pulls/27 --- llm-fe/src/axiosApi.js | 10 +- llm-fe/src/index.tsx | 6 +- llm-fe/src/llm-fe/platform/AppRouter.test.tsx | 63 +++++++++++++ llm-fe/src/llm-fe/platform/AppRouter.tsx | 17 ++++ llm-fe/src/llm-fe/platform/nativePlatform.js | 65 +++++++++++++ .../llm-fe/platform/nativePlatform.test.js | 92 +++++++++++++++++++ llm-fe/src/react-app-env.d.ts | 18 ++++ 7 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 llm-fe/src/llm-fe/platform/AppRouter.test.tsx create mode 100644 llm-fe/src/llm-fe/platform/AppRouter.tsx create mode 100644 llm-fe/src/llm-fe/platform/nativePlatform.js create mode 100644 llm-fe/src/llm-fe/platform/nativePlatform.test.js diff --git a/llm-fe/src/axiosApi.js b/llm-fe/src/axiosApi.js index a143e20..2b79527 100644 --- a/llm-fe/src/axiosApi.js +++ b/llm-fe/src/axiosApi.js @@ -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); diff --git a/llm-fe/src/index.tsx b/llm-fe/src/index.tsx index 1fa274d..55743fa 100644 --- a/llm-fe/src/index.tsx +++ b/llm-fe/src/index.tsx @@ -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( - + @@ -33,6 +33,6 @@ root.render( - + ); diff --git a/llm-fe/src/llm-fe/platform/AppRouter.test.tsx b/llm-fe/src/llm-fe/platform/AppRouter.test.tsx new file mode 100644 index 0000000..e6e29d3 --- /dev/null +++ b/llm-fe/src/llm-fe/platform/AppRouter.test.tsx @@ -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( + + + Account Page} /> + Home} /> + + , + ); + + expect(screen.getByText('Account Page')).toBeInTheDocument(); + }); + + it('renders nested routes under BrowserRouter on web', () => { + delete window.Capacitor; + window.history.pushState({}, '', '/account/'); + + render( + + + Account Page} /> + Home} /> + + , + ); + + expect(screen.getByText('Account Page')).toBeInTheDocument(); + }); +}); diff --git a/llm-fe/src/llm-fe/platform/AppRouter.tsx b/llm-fe/src/llm-fe/platform/AppRouter.tsx new file mode 100644 index 0000000..cdec648 --- /dev/null +++ b/llm-fe/src/llm-fe/platform/AppRouter.tsx @@ -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 {children}; +}; + +export default AppRouter; diff --git a/llm-fe/src/llm-fe/platform/nativePlatform.js b/llm-fe/src/llm-fe/platform/nativePlatform.js new file mode 100644 index 0000000..a976e3d --- /dev/null +++ b/llm-fe/src/llm-fe/platform/nativePlatform.js @@ -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; +} diff --git a/llm-fe/src/llm-fe/platform/nativePlatform.test.js b/llm-fe/src/llm-fe/platform/nativePlatform.test.js new file mode 100644 index 0000000..80ebe68 --- /dev/null +++ b/llm-fe/src/llm-fe/platform/nativePlatform.test.js @@ -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/'); + }); + }); +}); diff --git a/llm-fe/src/react-app-env.d.ts b/llm-fe/src/react-app-env.d.ts index 6431bc5..2e4afca 100644 --- a/llm-fe/src/react-app-env.d.ts +++ b/llm-fe/src/react-app-env.d.ts @@ -1 +1,19 @@ /// + +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; + remove?: (options: { key: string }) => Promise; + }; + }; +} + +interface Window { + Capacitor?: CapacitorBridge; +}