diff --git a/.yarn/changelogs/frontend.bb2aa909.md b/.yarn/changelogs/frontend.bb2aa909.md new file mode 100644 index 00000000..209bc378 --- /dev/null +++ b/.yarn/changelogs/frontend.bb2aa909.md @@ -0,0 +1,36 @@ + +# frontend + + + +## ⚡ Performance + +- Converted route components to lazy-loaded dynamic imports using `PiRatLazyLoad`, enabling code splitting for all page-level routes (movies, series, IoT devices, dashboards, login, register) + +## 🐛 Bug Fixes + +- Fixed scroll behavior in chat and AI chat message lists by moving scroll container styles from shadow DOM host CSS to inner wrapper elements +- Fixed terminal initialization timing in `LogEntriesTerminal` by deferring `terminal.open()` to a microtask, ensuring the container element is mounted in the DOM before opening +- Fixed form reset in chat and AI chat message inputs by using properly typed `HTMLFormElement` refs instead of workaround `querySelector` logic + +## ♻️ Refactoring + +- Renamed `urlString` to `href` in `IconUrlWidget` for semantic clarity + +## 🧪 Tests + +- Added unit tests for `navigateToRoute` covering `pushState`, `replaceState`, route parameter compilation, and query string handling diff --git a/.yarn/changelogs/pi-rat.bb2aa909.md b/.yarn/changelogs/pi-rat.bb2aa909.md new file mode 100644 index 00000000..c18ba6e0 --- /dev/null +++ b/.yarn/changelogs/pi-rat.bb2aa909.md @@ -0,0 +1,22 @@ + +# pi-rat + + + +## 🔧 Chores + +- Upgraded to Shades 12 with lazy-loaded route components and shadow DOM compatibility fixes diff --git a/.yarn/versions/bb2aa909.yml b/.yarn/versions/bb2aa909.yml new file mode 100644 index 00000000..8c971386 --- /dev/null +++ b/.yarn/versions/bb2aa909.yml @@ -0,0 +1,3 @@ +releases: + frontend: patch + pi-rat: patch diff --git a/frontend/src/app-routes.tsx b/frontend/src/app-routes.tsx index 255ab394..e7c1e736 100644 --- a/frontend/src/app-routes.tsx +++ b/frontend/src/app-routes.tsx @@ -9,21 +9,11 @@ import { import { AppBarLink, type AppBarLinkProps } from '@furystack/shades-common-components' import { decode } from 'common' import type { MatchResult } from 'path-to-regexp' -import { LoadableDashboard } from './components/dashboard/LoadableDashboard.js' -import { DefaultDashboard } from './components/dashboard/default-dashboard.js' import { PiRatLazyLoad } from './components/pirat-lazy-load.js' import { navigateToRoute } from './navigate-to-route.js' -import { DeviceList } from './pages/iot/device-list.js' -import { Login } from './pages/login.js' -import { MovieList } from './pages/movies/movie-list.js' -import { MovieLoader } from './pages/movies/movie-loader.js' -import { MovieOverview } from './pages/movies/movie-overview.js' -import { SeriesList } from './pages/movies/series-list.js' -import { SeriesOverview } from './pages/movies/series-overview.js' -import { Register } from './pages/register.js' /** - * Like ExtractRoutePaths from @furystack/shades but with NestedRoute constraint + * Like ExtractRoutePaths from @furystack/shades but with NestedRoute constraint * to support routes with specific match parameter types. */ type ConcatPaths = Parent extends '/' ? Child : `${Parent}${Child}` @@ -107,22 +97,53 @@ const settingsChildren = { export const appRoutes = { '/movies': { - component: () => , + component: () => ( + { + const { MovieList } = await import('./pages/movies/movie-list.js') + return + }} + /> + ), }, '/movies/:id/watch': { - component: ({ match }: { match: MatchResult<{ id: string }> }) => , + component: ({ match }: { match: MatchResult<{ id: string }> }) => ( + { + const { MovieLoader } = await import('./pages/movies/movie-loader.js') + return + }} + /> + ), }, '/movies/:imdbId/overview': { component: ({ match }: { match: MatchResult<{ imdbId: string }> }) => ( - + { + const { MovieOverview } = await import('./pages/movies/movie-overview.js') + return + }} + /> ), }, '/series': { - component: () => , + component: () => ( + { + const { SeriesList } = await import('./pages/movies/series-list.js') + return + }} + /> + ), }, '/series/:imdbId': { component: ({ match }: { match: MatchResult<{ imdbId: string }> }) => ( - + { + const { SeriesOverview } = await import('./pages/movies/series-overview.js') + return + }} + /> ), }, '/app-settings': { @@ -257,7 +278,14 @@ export const appRoutes = { ), }, '/iot/devices': { - component: () => , + component: () => ( + { + const { DeviceList } = await import('./pages/iot/device-list.js') + return + }} + /> + ), }, '/iot/device/:id': { component: ({ match }: { match: MatchResult<{ id: string }> }) => ( @@ -315,20 +343,48 @@ export const appRoutes = { ), }, '/dashboards/:id': { - component: ({ match }: { match: MatchResult<{ id: string }> }) => , + component: ({ match }: { match: MatchResult<{ id: string }> }) => ( + { + const { LoadableDashboard } = await import('./components/dashboard/LoadableDashboard.js') + return + }} + /> + ), }, '/': { routingOptions: { end: false }, - component: () => , + component: () => ( + { + const { DefaultDashboard } = await import('./components/dashboard/default-dashboard.js') + return + }} + /> + ), }, } export const authRoutes = { '/register': { - component: () => , + component: () => ( + { + const { Register } = await import('./pages/register.js') + return + }} + /> + ), }, '': { - component: () => , + component: () => ( + { + const { Login } = await import('./pages/login.js') + return + }} + /> + ), }, } diff --git a/frontend/src/components/dashboard/icon-url-widget.tsx b/frontend/src/components/dashboard/icon-url-widget.tsx index f4b508f6..cb9cce93 100644 --- a/frontend/src/components/dashboard/icon-url-widget.tsx +++ b/frontend/src/components/dashboard/icon-url-widget.tsx @@ -103,10 +103,10 @@ export const IconUrlWidget = Shade({ } }) - const urlString: string = props.url + const href: string = props.url return ( - +
{ + let pushStateSpy: ReturnType + let replaceStateSpy: ReturnType + + beforeEach(() => { + pushStateSpy = vi.spyOn(window.history, 'pushState').mockImplementation(() => {}) + replaceStateSpy = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should call pushState with the path', async () => { + await usingAsync(new Injector(), async (injector) => { + const updateState = vi.fn() + injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService) + + navigateToRoute(injector, '/movies') + + expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/movies') + }) + }) + + it('should call LocationService.updateState()', async () => { + await usingAsync(new Injector(), async (injector) => { + const updateState = vi.fn() + injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService) + + navigateToRoute(injector, '/movies') + + expect(updateState).toHaveBeenCalled() + }) + }) + + it('should compile route with params', async () => { + await usingAsync(new Injector(), async (injector) => { + const updateState = vi.fn() + injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService) + + navigateToRoute(injector, '/movies/:imdbId/overview', { imdbId: 'tt1234567' }) + + expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/movies/tt1234567/overview') + }) + }) + + it('should append queryString when provided', async () => { + await usingAsync(new Injector(), async (injector) => { + const updateState = vi.fn() + injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService) + + navigateToRoute(injector, '/entities/movies', {}, { queryString: 'gedst=%7B%22mode%22%3A%22edit%22%7D' }) + + expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/entities/movies?gedst=%7B%22mode%22%3A%22edit%22%7D') + }) + }) + + it('should call replaceState when options.replace is true', async () => { + await usingAsync(new Injector(), async (injector) => { + const updateState = vi.fn() + injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService) + + navigateToRoute(injector, '/app-settings/omdb', {}, { replace: true }) + + expect(replaceStateSpy).toHaveBeenCalledWith({}, '', '/app-settings/omdb') + expect(pushStateSpy).not.toHaveBeenCalled() + }) + }) + + it('should call pushState by default (replace not set)', async () => { + await usingAsync(new Injector(), async (injector) => { + const updateState = vi.fn() + injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService) + + navigateToRoute(injector, '/series') + + expect(pushStateSpy).toHaveBeenCalled() + expect(replaceStateSpy).not.toHaveBeenCalled() + }) + }) + + it('should navigate to path without params when none provided', async () => { + await usingAsync(new Injector(), async (injector) => { + const updateState = vi.fn() + injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService) + + navigateToRoute(injector, '/chat') + + expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/chat') + }) + }) +}) diff --git a/frontend/src/pages/ai/ai-chat-input.tsx b/frontend/src/pages/ai/ai-chat-input.tsx index 9a2eb738..c20264a4 100644 --- a/frontend/src/pages/ai/ai-chat-input.tsx +++ b/frontend/src/pages/ai/ai-chat-input.tsx @@ -15,7 +15,7 @@ export const AiChatInput = Shade<{ selectedChatId: string }>({ const aiChatMessageService = injector.getInstance(AiChatMessageService) const aiChatService = injector.getInstance(AiChatService) const sessionService = injector.getInstance(SessionService) - const formRef = useRef('form') + const formRef = useRef('form') const [selectedChat] = useObservable( 'selectedChat', @@ -41,10 +41,7 @@ export const AiChatInput = Shade<{ selectedChatId: string }>({ visibility: selectedChat?.value?.entries[0]?.visibility ?? 'private', }) .then(() => { - const form = formRef.current?.querySelector('form') ?? formRef.current - if (form && 'reset' in form) { - ;(form as HTMLFormElement).reset() - } + formRef.current?.reset() }) }} validate={(formData): formData is { message: string } => { diff --git a/frontend/src/pages/ai/ai-chat-message-list.tsx b/frontend/src/pages/ai/ai-chat-message-list.tsx index 02dc24e6..1d2e35d1 100644 --- a/frontend/src/pages/ai/ai-chat-message-list.tsx +++ b/frontend/src/pages/ai/ai-chat-message-list.tsx @@ -10,11 +10,9 @@ export const AiChatMessageList = Shade<{ }>({ shadowDomName: 'pi-rat-ai-chat-message-list', css: { - display: 'flex', - flexDirection: 'column', + display: 'block', width: '100%', height: 'calc(100% - 124px)', - overflowY: 'auto', }, render: ({ useObservable, injector, props, useDisposable, useRef }) => { const { selectedChatId } = props @@ -77,7 +75,16 @@ export const AiChatMessageList = Shade<{ scrollToBottom() return ( - <> +
{messages.value.result.entries.map((message) => { try { const fromJson = JSON.parse(message.content) as { content: string; thinking?: string } @@ -103,7 +110,7 @@ export const AiChatMessageList = Shade<{ ) } })} - +
) }, }) diff --git a/frontend/src/pages/chat/message-input.tsx b/frontend/src/pages/chat/message-input.tsx index 77d79606..e825afb3 100644 --- a/frontend/src/pages/chat/message-input.tsx +++ b/frontend/src/pages/chat/message-input.tsx @@ -10,7 +10,7 @@ export const MessageInput = Shade<{ chat: Chat }>({ const chatService = injector.getInstance(ChatMessageService) const session = injector.getInstance(SessionService) const theme = injector.getInstance(ThemeProviderService) - const formRef = useRef('form') + const formRef = useRef('form') return ( @@ -24,10 +24,7 @@ export const MessageInput = Shade<{ chat: Chat }>({ owner: session.currentUser.getValue()?.username || '', attachments: [], }) - const form = formRef.current?.querySelector('form') ?? formRef.current - if (form && 'reset' in form) { - ;(form as HTMLFormElement).reset() - } + formRef.current?.reset() }} validate={(formData: unknown): formData is { content: string } => { return ( diff --git a/frontend/src/pages/chat/message-list.tsx b/frontend/src/pages/chat/message-list.tsx index 3d771271..a86933be 100644 --- a/frontend/src/pages/chat/message-list.tsx +++ b/frontend/src/pages/chat/message-list.tsx @@ -43,12 +43,9 @@ const ChatLineAvatar = styledElement('div', { export const MessageList = Shade<{ chat: Chat }>({ shadowDomName: 'shade-app-message-list', css: { - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - gap: '8px', - overflowY: 'auto', + display: 'block', width: '100%', + height: '100%', }, render: ({ injector, props, useObservable, useRef }) => { const listRef = useRef('list') @@ -95,7 +92,18 @@ export const MessageList = Shade<{ chat: Chat }>({ } return ( - <> +
{chatMessages.value.entries.map((message) => ( @@ -119,7 +127,7 @@ export const MessageList = Shade<{ chat: Chat }>({
))} - +
) }, }) diff --git a/frontend/src/pages/logging/log-entries-terminal.tsx b/frontend/src/pages/logging/log-entries-terminal.tsx index 5bdf52ff..42836347 100644 --- a/frontend/src/pages/logging/log-entries-terminal.tsx +++ b/frontend/src/pages/logging/log-entries-terminal.tsx @@ -13,10 +13,10 @@ import { LoggingService } from '../../services/logging-service.js' const useDisposableTerminal = ( { useDisposable, injector }: Pick, 'useDisposable' | 'injector'>, - containerEl: HTMLElement | null, + containerRef: { current: HTMLElement | null }, ) => { return useDisposable('terminal', () => { - if (!containerEl) { + if (!containerRef.current) { const dummyTerminal = new Terminal() return { terminal: dummyTerminal, @@ -46,8 +46,13 @@ const useDisposableTerminal = ( terminal.loadAddon(fitAddon) terminal.loadAddon(searchAddon) terminal.loadAddon(webLinksAddon) - terminal.open(containerEl) - fitAddon.fit() + + queueMicrotask(() => { + const container = containerRef.current + if (!container) return + terminal.open(container) + fitAddon.fit() + }) return { terminal, @@ -117,7 +122,7 @@ export const LogEntriesTerminal = Shade({ }, render: (renderOptions) => { const containerRef = renderOptions.useRef('container') - const { terminal } = useDisposableTerminal(renderOptions, containerRef.current) + const { terminal } = useDisposableTerminal(renderOptions, containerRef) const [entries] = useLogEntries(renderOptions, terminal) fillTerminalWithLogEntries(terminal, entries)