diff --git a/src/app.spec.tsx b/src/app.spec.tsx
index 096f3d8..a98b954 100644
--- a/src/app.spec.tsx
+++ b/src/app.spec.tsx
@@ -1,7 +1,35 @@
-import { describe, it, expect, vi } from 'vitest';
+import { beforeEach, describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { App } from './app';
+type MockSearch = {
+ wps?: string;
+};
+
+type MockWaypoint = {
+ id: string;
+ geocodeResults: Array<{
+ selected?: boolean;
+ sourcelnglat?: [number, number];
+ }>;
+ userInput: string;
+};
+
+const mockUseParams = vi.fn(() => ({ activeTab: 'directions' }));
+const mockUseSearch = vi.fn<() => MockSearch>(() => ({ wps: undefined }));
+const mockRefetchDirections = vi.fn().mockResolvedValue(undefined);
+const mockReverseGeocode = vi.fn().mockResolvedValue([]);
+let mockMapReady = true;
+let mockWaypoints: MockWaypoint[] = [
+ { id: '0', geocodeResults: [], userInput: '' },
+ { id: '1', geocodeResults: [], userInput: '' },
+];
+
+vi.mock('@tanstack/react-router', () => ({
+ useParams: () => mockUseParams(),
+ useSearch: () => mockUseSearch(),
+}));
+
vi.mock('react-map-gl/maplibre', () => ({
MapProvider: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -32,7 +60,38 @@ vi.mock('@/components/ui/sonner', () => ({
),
}));
+vi.mock('@/hooks/use-directions-queries', () => ({
+ useDirectionsQuery: () => ({
+ refetch: mockRefetchDirections,
+ }),
+ useReverseGeocodeDirections: () => ({
+ reverseGeocode: mockReverseGeocode,
+ }),
+}));
+
+vi.mock('@/stores/common-store', () => ({
+ useCommonStore: (selector: (state: { mapReady: boolean }) => unknown) =>
+ selector({ mapReady: mockMapReady }),
+}));
+
+vi.mock('@/stores/directions-store', () => ({
+ useDirectionsStore: (
+ selector: (state: { waypoints: typeof mockWaypoints }) => unknown
+ ) => selector({ waypoints: mockWaypoints }),
+}));
+
describe('App', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseParams.mockReturnValue({ activeTab: 'directions' });
+ mockUseSearch.mockReturnValue({ wps: undefined });
+ mockMapReady = true;
+ mockWaypoints = [
+ { id: '0', geocodeResults: [], userInput: '' },
+ { id: '1', geocodeResults: [], userInput: '' },
+ ];
+ });
+
it('should render without crashing', () => {
expect(() => render()).not.toThrow();
});
@@ -73,4 +132,60 @@ describe('App', () => {
expect(mapProvider).toContainElement(screen.getByTestId('settings-panel'));
expect(mapProvider).toContainElement(screen.getByTestId('toaster'));
});
+
+ it('should hydrate waypoints from URL search params on initial load', () => {
+ mockUseSearch.mockReturnValue({
+ wps: '13.343067169189455,52.5296422146409,13.33414077758789,52.50901237642168',
+ });
+
+ render();
+
+ expect(mockReverseGeocode).toHaveBeenCalledTimes(2);
+ expect(mockReverseGeocode).toHaveBeenNthCalledWith(
+ 1,
+ 13.343067169189455,
+ 52.5296422146409,
+ 0,
+ { isPermalink: true }
+ );
+ expect(mockReverseGeocode).toHaveBeenNthCalledWith(
+ 2,
+ 13.33414077758789,
+ 52.50901237642168,
+ 1,
+ { isPermalink: true }
+ );
+ });
+
+ it('should auto-render directions when URL waypoints already exist in state', () => {
+ mockUseSearch.mockReturnValue({
+ wps: '13.343067169189455,52.5296422146409,13.33414077758789,52.50901237642168',
+ });
+ mockWaypoints = [
+ {
+ id: '0',
+ geocodeResults: [
+ {
+ selected: true,
+ sourcelnglat: [13.343067169189455, 52.5296422146409],
+ },
+ ],
+ userInput: 'Waypoint 1',
+ },
+ {
+ id: '1',
+ geocodeResults: [
+ {
+ selected: true,
+ sourcelnglat: [13.33414077758789, 52.50901237642168],
+ },
+ ],
+ userInput: 'Waypoint 2',
+ },
+ ];
+
+ render();
+
+ expect(mockRefetchDirections).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/src/app.tsx b/src/app.tsx
index 9fd9be8..cf96918 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -1,10 +1,110 @@
+import { useEffect, useMemo, useRef } from 'react';
+import { useParams, useSearch } from '@tanstack/react-router';
import { MapProvider } from 'react-map-gl/maplibre';
import { MapComponent } from './components/map';
import { RoutePlanner } from './components/route-planner';
import { SettingsPanel } from './components/settings-panel/settings-panel';
import { Toaster } from '@/components/ui/sonner';
+import {
+ useDirectionsQuery,
+ useReverseGeocodeDirections,
+} from '@/hooks/use-directions-queries';
+import { useCommonStore } from '@/stores/common-store';
+import { useDirectionsStore } from '@/stores/directions-store';
+import { isValidCoordinates } from '@/utils/geom';
export const App = () => {
+ const { activeTab } = useParams({ from: '/$activeTab' });
+ const { wps } = useSearch({ from: '/$activeTab' });
+ const { refetch: refetchDirections } = useDirectionsQuery();
+ const { reverseGeocode } = useReverseGeocodeDirections();
+ const mapReady = useCommonStore((state) => state.mapReady);
+ const waypoints = useDirectionsStore((state) => state.waypoints);
+ const initialWpsRef = useRef(wps);
+ const urlWaypointsLoadedRef = useRef(false);
+ const urlRouteRenderedRef = useRef(false);
+
+ const activeWaypointCount = useMemo(
+ () =>
+ waypoints.filter((wp) =>
+ wp.geocodeResults.some((result) => result.selected)
+ ).length,
+ [waypoints]
+ );
+
+ useEffect(() => {
+ const initialWps = initialWpsRef.current;
+
+ if (
+ activeTab !== 'directions' ||
+ !initialWps ||
+ urlWaypointsLoadedRef.current
+ ) {
+ return;
+ }
+
+ urlWaypointsLoadedRef.current = true;
+ console.info('[App] Hydrating directions from URL search params', {
+ wps: initialWps,
+ });
+
+ const coordinates = initialWps.split(',').map(Number);
+ let hasValidWaypoint = false;
+
+ for (let i = 0; i < coordinates.length; i += 2) {
+ const lng = coordinates[i];
+ const lat = coordinates[i + 1];
+
+ if (
+ lng === undefined ||
+ lat === undefined ||
+ Number.isNaN(lng) ||
+ Number.isNaN(lat) ||
+ !isValidCoordinates(lat, lng)
+ ) {
+ continue;
+ }
+
+ hasValidWaypoint = true;
+
+ void reverseGeocode(lng, lat, i / 2, { isPermalink: true }).catch(
+ (error) => {
+ console.error('[App] Failed to hydrate waypoint from URL', error);
+ }
+ );
+ }
+
+ if (!hasValidWaypoint) {
+ console.debug(
+ '[App] No valid route coordinates found in URL search params'
+ );
+ }
+ }, [activeTab, reverseGeocode]);
+
+ useEffect(() => {
+ const initialWps = initialWpsRef.current;
+
+ if (
+ activeTab !== 'directions' ||
+ !initialWps ||
+ !mapReady ||
+ urlRouteRenderedRef.current ||
+ activeWaypointCount < 2
+ ) {
+ return;
+ }
+
+ urlRouteRenderedRef.current = true;
+ console.info('[App] Auto-rendering route from URL search params', {
+ activeWaypointCount,
+ });
+
+ void refetchDirections().catch((error) => {
+ console.error('[App] Failed to auto-render route from URL', error);
+ urlRouteRenderedRef.current = false;
+ });
+ }, [activeTab, activeWaypointCount, mapReady, refetchDirections]);
+
return (
diff --git a/src/components/directions/directions.spec.tsx b/src/components/directions/directions.spec.tsx
index b888329..2dbeb41 100644
--- a/src/components/directions/directions.spec.tsx
+++ b/src/components/directions/directions.spec.tsx
@@ -5,7 +5,6 @@ import { DirectionsControl } from './directions';
const mockNavigate = vi.fn();
const mockRefetchDirections = vi.fn();
-const mockReverseGeocode = vi.fn().mockResolvedValue([]);
const mockAddEmptyWaypointToEnd = vi.fn();
const mockClearWaypoints = vi.fn();
const mockClearRoutes = vi.fn();
@@ -15,10 +14,6 @@ vi.mock('@tanstack/react-router', () => ({
useNavigate: vi.fn(() => mockNavigate),
}));
-vi.mock('@/utils/parse-url-params', () => ({
- parseUrlParams: vi.fn(() => ({})),
-}));
-
const mockWaypoints = [
{ id: '0', geocodeResults: [], userInput: '' },
{ id: '1', geocodeResults: [], userInput: '' },
@@ -63,9 +58,6 @@ vi.mock('@/hooks/use-directions-queries', () => ({
useDirectionsQuery: vi.fn(() => ({
refetch: mockRefetchDirections,
})),
- useReverseGeocodeDirections: vi.fn(() => ({
- reverseGeocode: mockReverseGeocode,
- })),
}));
vi.mock('@/hooks/use-optimized-route-query', () => ({
@@ -292,89 +284,3 @@ describe('DirectionsControl', () => {
expect(result.wps).toBe('13.4,52.5,10,48');
});
});
-
-describe('DirectionsControl URL parsing', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- mockResults.data = null;
- mockWaypoints.length = 0;
- mockWaypoints.push(
- { id: '0', geocodeResults: [], userInput: '' },
- { id: '1', geocodeResults: [], userInput: '' }
- );
- });
-
- it('should process URL params with valid coordinates (Berlin)', async () => {
- const parseUrlParams = await import('@/utils/parse-url-params');
- vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({
- wps: '13.365016850476763,52.483706198952575,13.422421655040836,52.49336042169804',
- });
-
- render();
-
- expect(mockReverseGeocode).toHaveBeenCalledTimes(2);
- expect(mockReverseGeocode).toHaveBeenCalledWith(
- 13.365016850476763,
- 52.483706198952575,
- 0,
- { isPermalink: true }
- );
- expect(mockReverseGeocode).toHaveBeenCalledWith(
- 13.422421655040836,
- 52.49336042169804,
- 1,
- { isPermalink: true }
- );
- });
-
- it('should process URL params with valid coordinates where lng > 90 (Singapore)', async () => {
- const parseUrlParams = await import('@/utils/parse-url-params');
- vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({
- wps: '103.66492937866911,1.4827280571964963,103.66421854954496,1.4840285187178779',
- });
-
- render();
-
- expect(mockReverseGeocode).toHaveBeenCalledTimes(2);
- expect(mockReverseGeocode).toHaveBeenCalledWith(
- 103.66492937866911,
- 1.4827280571964963,
- 0,
- { isPermalink: true }
- );
- expect(mockReverseGeocode).toHaveBeenCalledWith(
- 103.66421854954496,
- 1.4840285187178779,
- 1,
- { isPermalink: true }
- );
- });
-
- it('should skip truly invalid coordinates from URL', async () => {
- const parseUrlParams = await import('@/utils/parse-url-params');
- vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({
- wps: '999,999',
- });
-
- render();
-
- expect(mockReverseGeocode).not.toHaveBeenCalled();
- });
-
- it('should handle coordinates near edge of valid range', async () => {
- const parseUrlParams = await import('@/utils/parse-url-params');
- vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({
- wps: '179.9,89,-179.9,-89',
- });
-
- render();
-
- expect(mockReverseGeocode).toHaveBeenCalledTimes(2);
- expect(mockReverseGeocode).toHaveBeenCalledWith(179.9, 89, 0, {
- isPermalink: true,
- });
- expect(mockReverseGeocode).toHaveBeenCalledWith(-179.9, -89, 1, {
- isPermalink: true,
- });
- });
-});
diff --git a/src/components/directions/directions.tsx b/src/components/directions/directions.tsx
index 3147dd5..d5344c1 100644
--- a/src/components/directions/directions.tsx
+++ b/src/components/directions/directions.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef } from 'react';
+import { useCallback, useEffect } from 'react';
import { Waypoints } from './waypoints/waypoint-list';
@@ -11,14 +11,9 @@ import type { ParsedDirectionsGeometry } from '@/components/types';
import { Button } from '@/components/ui/button';
import { MapPinPlus, MapPinXInside } from 'lucide-react';
import { RouteCard } from './route-card';
-import { parseUrlParams } from '@/utils/parse-url-params';
-import { isValidCoordinates } from '@/utils/geom';
import { useNavigate } from '@tanstack/react-router';
import { useDirectionsStore } from '@/stores/directions-store';
-import {
- useDirectionsQuery,
- useReverseGeocodeDirections,
-} from '@/hooks/use-directions-queries';
+import { useDirectionsQuery } from '@/hooks/use-directions-queries';
import { useOptimizedRouteQuery } from '@/hooks/use-optimized-route-query';
import { Sparkles } from 'lucide-react';
import {
@@ -35,13 +30,10 @@ export const DirectionsControl = () => {
);
const clearWaypoints = useDirectionsStore((state) => state.clearWaypoints);
const clearRoutes = useDirectionsStore((state) => state.clearRoutes);
- const initialUrlParams = useRef(parseUrlParams());
- const urlParamsProcessed = useRef(false);
const navigate = useNavigate({ from: '/$activeTab' });
const updateDateTime = useCommonStore((state) => state.updateDateTime);
const dateTime = useCommonStore((state) => state.dateTime);
const { refetch: refetchDirections } = useDirectionsQuery();
- const { reverseGeocode } = useReverseGeocodeDirections();
const { optimizeRoute, isPending: isOptimizing } = useOptimizedRouteQuery();
const isOptimized = useDirectionsStore((state) => state.isOptimized);
const activeRouteIndex = useDirectionsStore(
@@ -51,30 +43,6 @@ export const DirectionsControl = () => {
(state) => state.setActiveRouteIndex
);
- useEffect(() => {
- if (urlParamsProcessed.current) return;
-
- const wpsParam = initialUrlParams.current.wps;
-
- if (wpsParam) {
- const coordinates = wpsParam.split(',').map(Number);
-
- for (let i = 0; i < coordinates.length; i += 2) {
- const lng = coordinates[i]!;
- const lat = coordinates[i + 1]!;
-
- if (!isValidCoordinates(lat, lng) || isNaN(lng) || isNaN(lat)) continue;
-
- const index = i / 2;
- reverseGeocode(lng, lat, index, { isPermalink: true });
- }
- refetchDirections();
- }
-
- urlParamsProcessed.current = true;
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
useEffect(() => {
const wps: number[] = [];