0.4.3 • Published 5 months ago

@front-utils/router v0.4.3

Weekly downloads
-
License
ISC
Repository
github
Last release
5 months ago

Router Utility

A lightweight, flexible hash-based router implementation for modern web applications.

Table of Contents

Overview

This utility provides a hash-based navigation system that can be used to create single-page applications with client-side routing. It consists of two main components:

  1. HashNavigation - A low-level API for managing browser history and hash-based navigation
  2. HashRouter - A higher-level abstraction that provides an easy-to-use router interface
  3. ClientRouter - A React component for declarative route rendering

Installation

# Using npm
npm install @front-utils/router

# Using yarn
yarn add @front-utils/router

# Using bun
bun add @front-utils/router

Browser Compatibility

Router Utility is compatible with all modern browsers that support the History API:

  • Chrome 49+
  • Firefox 45+
  • Safari 10+
  • Edge 12+
  • Opera 36+

For older browsers, consider using a polyfill for the History API.

API Documentation

HashNavigation

HashNavigation provides direct access to browser history and navigation functionality. It's designed as a lightweight wrapper around the browser's History API.

It almost completely implements the native object window.navigation

Properties

  • currentEntry - A read-only signal containing the current history entry
  • entries - A read-only signal containing all history entries
  • canGoBack - A read-only signal indicating if navigation backward is possible
  • canGoForward - A read-only signal indicating if navigation forward is possible

Methods

  • navigate(hash, options) - Navigate to a new hash, optionally with state
  • traverseTo(key, options) - Navigate to a specific history entry by key
  • back(options) - Navigate backward in history
  • forward(options) - Navigate forward in history
  • updateCurrentEntry(options, hash) - Update the state of the current entry, optionally updating the hash
  • updateCurrentEntryHash(hash, newState?: NavigationState | null | undefined) - Update hash and state of the current entry
  • create() - Initialize the navigation system
  • subscribe(callback) - Subscribe to navigation changes with callback receiving current entry, previous entry, and hash
  • destroy() - Clean up all listeners and resources

HashRouter

HashRouter provides a higher-level API for common routing operations, with easier configuration and initialization.

Properties

  • _navigation - Reference to the underlying HashNavigation instance
  • currentEntry - A read-only signal containing the current router history entry with extended functionality
  • state - A read-only signal providing access to the current navigation state
  • hash - A read-only signal providing access to the current hash
  • entries - A read-only signal containing all history entries (inherited from HashNavigation)
  • canGoBack - A read-only signal indicating if navigation backward is possible (inherited from HashNavigation)
  • canGoForward - A read-only signal indicating if navigation forward is possible (inherited from HashNavigation)

Methods

  • create(config) - Initialize the router and subscribe to location changes
  • subscribe(callback) - Subscribe to history changes
  • navigate(hash, state) - Navigate to a specific hash with optional state
  • replaceState(config) - Replace the current state and/or hash
  • goBack() - Navigate back in history
  • goToPrev() - Alias for goBack
  • getHash() - Get the current hash
  • getState() - Get the current state
  • hasPage(hash?) - Check if a page exists in configured routes
  • destroy() - Clean up all listeners and resources
  • getConfig() - Get the current router configuration

RouterHistoryEntry

RouterHistoryEntry extends NavigationHistoryEntry with additional router-specific functionality.

Properties

  • All properties from NavigationHistoryEntry (url, key, id, index, sameDocument, state, hash)
  • pattern - The matched route pattern, if any

Methods

  • getParams<T>() - Get URL parameters extracted from the route pattern
  • getQuery<T>() - Get query parameters from the URL

ClientRouter

ClientRouter is a React component that provides a declarative way to define and render routes in your React application. It automatically handles route changes, component rendering, and passing route parameters as props.

Props

  • router: (HashRouter) - An instance of HashRouter to use for navigation
  • routes: (Map<string, React.ComponentType>) - A Map of route patterns to React components
  • homeUrl: (string) - The default route to redirect to when no route matches
  • notFoundComponent: (React.ComponentType) - Component to render when no route matches
  • className: (string, optional) - CSS class to apply to the router container element

Usage

The ClientRouter simplifies route management in React applications by:

  1. Automatically subscribing to route changes
  2. Rendering the appropriate component for the current route
  3. Passing route parameters as props to the rendered component
  4. Handling "not found" routes with a custom component
  5. Cleaning up subscriptions when unmounted

Usage Examples

Basic Setup

import { hashRouter } from '@front-utils/router';

// Initialize the router with routes configuration
const unsubscribe = hashRouter.create({
  onChange: (location) => {
    console.log('Route changed:', location.hash);
    // Update your UI based on the new location
  },
  config: {
    homeUrl: 'home',
    routeNames: ['home', 'about', 'contact', 'products']
  }
});

// Navigate to a different route
hashRouter.navigate('about');

// Clean up when your app is destroyed
// unsubscribe();

Advanced Usage

import { hashRouter } from '@front-utils/router';

// Initialize with more complex state handling
hashRouter.create({
  onChange: (location) => {
    const hash = location.hash;
    const state = location.state;
    
    console.log('Current route:', hash);
    console.log('Route state:', state);
    
    // Update application state or UI based on the route
    renderPage(hash, state);
  },
  config: {
    homeUrl: 'dashboard',
    routeNames: ['dashboard', 'profile', 'settings', 'reports', 'logout']
  }
});

// Navigate with state
hashRouter.navigate('profile', { 
  userId: 123, 
  viewMode: 'edit' 
});

// Check if current route is valid
if (hashRouter.hasPage()) {
  console.log('Current route is valid');
} else {
  console.log('Invalid route, redirecting to home');
  hashRouter.navigate('dashboard');
}

Handling Route Changes

import { hashRouter } from '@front-utils/router';

// Create a route handler function
const handleRouteChange = (location) => {
  const hash = location.hash;
  const appContainer = document.getElementById('app');
  
  // Simple route-based content rendering
  switch (hash) {
    case 'home':
      appContainer.innerHTML = '<h1>Home Page</h1>';
      break;
    case 'about':
      appContainer.innerHTML = '<h1>About Us</h1>';
      break;
    case 'contact':
      appContainer.innerHTML = '<h1>Contact Us</h1>';
      break;
    default:
      appContainer.innerHTML = '<h1>Page Not Found</h1>';
      break;
  }
};

// Initialize the router
hashRouter.create({
  onChange: handleRouteChange,
  config: {
    homeUrl: 'home',
    routeNames: ['home', 'about', 'contact']
  }
});

// Create navigation buttons
document.getElementById('home-btn').addEventListener('click', () => {
  hashRouter.navigate('home');
});

document.getElementById('about-btn').addEventListener('click', () => {
  hashRouter.navigate('about');
});

document.getElementById('contact-btn').addEventListener('click', () => {
  hashRouter.navigate('contact');
});

document.getElementById('back-btn').addEventListener('click', () => {
  hashRouter.goBack();
});

State Management

import { hashRouter } from '@front-utils/router';

// Track a user's navigation through a product catalog
// Initialize with a product catalog setup
hashRouter.create({
  onChange: (location) => {
    const hash = location.hash;
    const state = location.state;
    
    if (hash === 'product') {
      const productId = state?.productId;
      if (productId) {
        // Fetch and display the product
        fetchProductDetails(productId).then(displayProduct);
      }
    }
  },
  config: {
    homeUrl: 'catalog',
    routeNames: ['catalog', 'product', 'cart', 'checkout']
  }
});

// Navigation to a specific product detail page
function viewProduct(productId) {
  hashRouter.navigate('product', { productId });
}

// Update state without changing the route
function updateProductOptions(options) {
  hashRouter.replaceState({ 
    state: { ...hashRouter.getState(), options } 
  });
}

// Example of accessing state from the current entry
function getCurrentProductId() {
  const state = hashRouter.getState();
  return state?.productId;
}

Route Parameters and Queries

import { hashRouter } from '@front-utils/router';

// Setup routes with parameters
hashRouter.create({
  onChange: (location) => {
    // Get route parameters using the extended RouterHistoryEntry
    const params = hashRouter.currentEntry.value.getParams();
    const query = hashRouter.currentEntry.value.getQuery();
    
    console.log('Route params:', params);
    console.log('Query params:', query);
    
    // Example: Rendering different views based on params and query
    if (location.hash.includes('users')) {
      if (params.id) {
        renderUserDetails(params.id, query.tab || 'profile');
      } else {
        renderUsersList(query.page || '1', query.sort || 'name');
      }
    }
  },
  config: {
    homeUrl: 'home',
    // Define routes including parameter patterns
    routeNames: [
      'home', 
      'users', 
      'users/:id', 
      'products/:category/:id'
    ]
  }
});

// Navigate to parameterized routes
hashRouter.navigate('users/123?tab=settings');
hashRouter.navigate('products/electronics/laptop-15?color=silver&price=999');

// Helper function to access params in components
function useRouteParams() {
  return hashRouter.currentEntry.value.getParams();
}

// Helper function to access query parameters
function useQueryParams() {
  return hashRouter.currentEntry.value.getQuery();
}

TypeScript Usage

import { hashRouter, NavigationHistoryEntry, InitializeRouterConfig, RouterHistoryEntry } from '@front-utils/router';

// Type-safe route configuration
interface AppRoutes {
  home: undefined;
  product: { id: string };
  checkout: { items: string[] };
}

// Define your routes
const routeConfig: InitializeRouterConfig = {
  homeUrl: 'home',
  routeNames: ['home', 'product', 'checkout']
};

// Type-safe route handler
function handleRouteChange(location: NavigationHistoryEntry): void {
  const hash = location.hash;
  
  // Access state in a type-safe way
  const state = hashRouter.getState();
  
  switch (hash) {
    case 'home':
      renderHomePage();
      break;
    case 'product':
      if (state && 'id' in state) {
        renderProductPage(state.id as string);
      }
      break;
    case 'checkout':
      if (state && 'items' in state) {
        renderCheckoutPage(state.items as string[]);
      }
      break;
  }
}

// Access route parameters with proper typing
function useTypedParams<T>() {
  return hashRouter.currentEntry.value.getParams<T>();
}

// Initialize with type-safe configuration
hashRouter.create({
  onChange: handleRouteChange,
  config: routeConfig
});

// Type-safe navigation with state
function navigateToProduct(productId: string): void {
  hashRouter.navigate('product', { id: productId });
}

Integration with Frameworks

React Integration

import React, { useState, useEffect, useMemo } from 'react';
import { hashRouter } from '@front-utils/router';

// Custom hook for using the router
function useHashRouter() {
  const [routerInstance, setRouterInstance] = useState(null);
  
  // Initialize once
  useEffect(() => {
    const unsubscribe = hashRouter.create({
      onChange: (location) => {
        // Force a re-render on location change
        setRouterInstance({});
      },
      config: {
        homeUrl: 'home',
        routeNames: ['home', 'users', 'users/:id', 'settings']
      }
    });
    
    return unsubscribe;
  }, []);
  
  // Return consistent references to current values and methods
  return useMemo(() => ({
    currentRoute: hashRouter.getHash(),
    routeState: hashRouter.getState() || {},
    currentEntry: hashRouter.currentEntry.value,
    params: hashRouter.currentEntry.value.getParams(),
    query: hashRouter.currentEntry.value.getQuery(),
    navigate: hashRouter.navigate,
    goBack: hashRouter.goBack,
    canGoBack: hashRouter.canGoBack.value
  }), [routerInstance]);
}

// Example component
function App() {
  const { currentRoute, params, query, navigate, goBack, canGoBack } = useHashRouter();
  
  return (
    <div>
      <nav>
        <button onClick={() => navigate('home')}>Home</button>
        <button onClick={() => navigate('users')}>Users</button>
        <button onClick={() => navigate('settings')}>Settings</button>
        {canGoBack && <button onClick={goBack}>Back</button>}
      </nav>
      
      <main>
        {currentRoute === 'home' && <HomePage />}
        {currentRoute === 'users' && !params.id && <UsersPage page={query.page} />}
        {currentRoute === 'users' && params.id && <UserDetailsPage userId={params.id} />}
        {currentRoute === 'settings' && <SettingsPage />}
      </main>
    </div>
  );
}

Using ClientRouter Component

The ClientRouter component provides a declarative way to define and manage routes in your React application:

import React, { useEffect } from 'react';
import { hashRouter, ClientRouter } from '@front-utils/router';

// Define page components
const HomePage = () => <div>Welcome to Home Page</div>;
const AboutPage = () => <div>About Us</div>;
const UserPage = ({ userId }) => <div>User Profile for User {userId}</div>;
const NotFoundPage = () => <div>404 - Page Not Found</div>;

function App() {
  
  // Create a map of routes to components
  const routes = new Map([
    ['home', HomePage],
    ['about', AboutPage],
    ['user/:userId', UserPage]
  ]);
  
  return (
    <div className="app">
      <nav>
        <button onClick={() => hashRouter.navigate('home')}>Home</button>
        <button onClick={() => hashRouter.navigate('about')}>About</button>
        <button onClick={() => hashRouter.navigate('user/123')}>User Profile</button>
      </nav>
      
      <main>
        <ClientRouter
          router={hashRouter}
          routes={routes}
          homeUrl="home"
          notFoundComponent={NotFoundPage}
          className="main-content"
        />
      </main>
    </div>
  );
}

export default App;

Advanced ClientRouter Examples:

  1. With TypeScript and route parameters:
import React, { useEffect } from 'react';
import { hashRouter, ClientRouter } from '@front-utils/router';

interface UserProfileProps {
  userId: string;
  tab?: string;
}

interface ProductProps {
  categoryId: string;
  productId: string;
  color?: string;
}

// Page components
const HomePage = () => <div>Home Page</div>;
const AboutPage = () => <div>About Page</div>;
const UserProfile = ({ userId, tab = 'profile' }: UserProfileProps) => (
  <div>
    <h1>User {userId}</h1>
    <div>Current tab: {tab}</div>
  </div>
);
const ProductPage = ({ categoryId, productId, color }: ProductProps) => (
  <div>
    <h1>Product: {productId}</h1>
    <div>Category: {categoryId}</div>
    {color && <div>Selected color: {color}</div>}
  </div>
);
const NotFoundPage = () => <div>Page Not Found</div>;

function App() {

  const routes = new Map([
    ['home', HomePage],
    ['about', AboutPage],
    ['users/:userId', UserProfile],
    ['products/:categoryId/:productId', ProductPage]
  ]);
  
  return (
    <div>
      <nav>
        <button onClick={() => hashRouter.navigate('home')}>Home</button>
        <button onClick={() => hashRouter.navigate('about')}>About</button>
        <button onClick={() => hashRouter.navigate('users/123?tab=settings')}>
          User 123
        </button>
        <button onClick={() => 
          hashRouter.navigate('products/electronics/laptop?color=silver')
        }>
          Silver Laptop
        </button>
      </nav>
      
      <ClientRouter
        router={hashRouter}
        routes={routes}
        homeUrl="home"
        notFoundComponent={NotFoundPage}
      />
    </div>
  );
}
  1. With React Context for better organization:
import React, { createContext, useContext, useEffect } from 'react';
import { hashRouter, ClientRouter } from '@front-utils/router';

// Create context
const RouterContext = createContext(hashRouter);

// Router provider component
function RouterProvider({ children }) {
  return (
    <RouterContext.Provider value={hashRouter}>
      {children}
    </RouterContext.Provider>
  );
}

// Custom hook for router access
function useRouter() {
  return useContext(RouterContext);
}

// Navigation component
function Navigation() {
  const router = useRouter();
  
  return (
    <nav>
      <button onClick={() => router.navigate('home')}>Home</button>
      <button onClick={() => router.navigate('dashboard')}>Dashboard</button>
      <button onClick={() => router.navigate('settings')}>Settings</button>
      {router.canGoBack.value && (
        <button onClick={() => router.goBack()}>Back</button>
      )}
    </nav>
  );
}

// Page components
const HomePage = () => <h1>Home</h1>;
const DashboardPage = () => <h1>Dashboard</h1>;
const ProfilePage = ({ userId }) => <h1>Profile: {userId}</h1>;
const SettingsPage = () => <h1>Settings</h1>;
const NotFoundPage = () => <h1>Page Not Found</h1>;

// Main app component
function MainApp() {
  const router = useRouter();
  const routes = new Map([
    ['home', HomePage],
    ['dashboard', DashboardPage],
    ['profile/:userId', ProfilePage],
    ['settings', SettingsPage]
  ]);
  
  return (
    <div className="app">
      <Navigation />
      <ClientRouter
        router={router}
        routes={routes}
        homeUrl="home"
        notFoundComponent={NotFoundPage}
      />
    </div>
  );
}

// Root component with provider
export default function App() {
  return (
    <RouterProvider>
      <MainApp />
    </RouterProvider>
  );
}
  1. With Code Splitting:
import React, { Suspense, lazy, useEffect } from 'react';
import { hashRouter, ClientRouter } from '@front-utils/router';

// Lazy loaded components
const HomePage = lazy(() => import('./pages/Home'));
const AboutPage = lazy(() => import('./pages/About'));
const UserProfilePage = lazy(() => import('./pages/UserProfile'));
const NotFoundPage = lazy(() => import('./pages/NotFound'));

// Loading component
const Loading = () => <div className="loading">Loading...</div>;

function App() {  
  const routes = new Map([
    ['home', HomePage],
    ['about', AboutPage],
    ['user/:userId', UserProfilePage]
  ]);
  
  return (
    <div className="app">
      <nav>
        <button onClick={() => hashRouter.navigate('home')}>Home</button>
        <button onClick={() => hashRouter.navigate('about')}>About</button>
        <button onClick={() => hashRouter.navigate('user/123')}>User Profile</button>
      </nav>
      
      <Suspense fallback={<Loading />}>
        <ClientRouter
          router={hashRouter}
          routes={routes}
          homeUrl="home"
          notFoundComponent={NotFoundPage}
        />
      </Suspense>
    </div>
  );
}

Vue Integration

// router.js
import { hashRouter } from '@front-utils/router';
import { ref, readonly, computed } from 'vue';

// Initialize the router
const unsubscribe = hashRouter.create({
  onChange: () => {
    // Vue's reactivity will pick up changes through the computed properties
  },
  config: {
    homeUrl: 'home',
    routeNames: ['home', 'about', 'contact', 'users/:id']
  }
});

// Create reactive references to router state
const currentHash = computed(() => hashRouter.getHash());
const currentState = computed(() => hashRouter.getState() || {});
const routeParams = computed(() => hashRouter.currentEntry.value.getParams());
const queryParams = computed(() => hashRouter.currentEntry.value.getQuery());
const canGoBack = computed(() => hashRouter.canGoBack.value);

export default {
  currentHash: readonly(currentHash),
  currentState: readonly(currentState),
  routeParams: readonly(routeParams),
  queryParams: readonly(queryParams),
  canGoBack: readonly(canGoBack),
  navigate: hashRouter.navigate,
  goBack: hashRouter.goBack,
  destroy: unsubscribe
};

// App.vue
<template>
  <div>
    <nav>
      <button @click="navigate('home')">Home</button>
      <button @click="navigate('about')">About</button>
      <button @click="navigate('contact')">Contact</button>
      <button @click="navigate(`users/${userId}`)">User Profile</button>
      <button v-if="canGoBack" @click="goBack">Back</button>
    </nav>
    
    <component :is="currentComponent" 
               :params="routeParams" 
               :query="queryParams"
               v-bind="currentState"></component>
  </div>
</template>

<script>
import router from './router';
import HomePage from './components/HomePage.vue';
import AboutPage from './components/AboutPage.vue';
import ContactPage from './components/ContactPage.vue';
import UserPage from './components/UserPage.vue';

export default {
  setup() {
    const userId = '123'; // Example user ID
    
    const getComponent = () => {
      const hash = router.currentHash.value;
      
      if (hash === 'home') return HomePage;
      if (hash === 'about') return AboutPage;
      if (hash === 'contact') return ContactPage;
      if (hash.startsWith('users/')) return UserPage;
      
      return HomePage; // Default
    };
    
    return {
      userId,
      currentComponent: computed(() => getComponent()),
      routeParams: router.routeParams,
      queryParams: router.queryParams,
      currentState: router.currentState,
      canGoBack: router.canGoBack,
      navigate: router.navigate,
      goBack: router.goBack
    };
  }
};
</script>

Contract Interfaces

The library is built on the following TypeScript interfaces:

HashNavigation Interface

export interface HashNavigation {
  // Public signals
  currentEntry: ReadonlySignal<NavigationHistoryEntry>;
  entries: ReadonlySignal<NavigationHistoryEntry[]>;
  canGoBack: ReadonlySignal<boolean>;
  canGoForward: ReadonlySignal<boolean>;
  
  // Navigation methods
  navigate: (hash: string, options?: NavigationOptions) => NavigationResult;
  traverseTo: (key: string, options?: NavigationOptions) => NavigationResult | null;
  back: (options?: NavigationOptions) => NavigationResult | null;
  forward: (options?: NavigationOptions) => NavigationResult | null;
  updateCurrentEntry: (options?: NavigationOptions) => void;
  
  // Subscription method
  subscribe: (
    callback: (
      entry: NavigationHistoryEntry,
      prevEntry: NavigationHistoryEntry | null,
      hash: string
    ) => void
  ) => VoidFunction;
  
  // Init
  create: () => void;
  // Cleanup
  destroy: () => void;

  updateCurrentEntryHash: (hash: string, newState?: NavigationState | null | undefined) => void;
}

HashRouter Interface

export interface HashRouter extends Pick<HashNavigation, 'entries' | 'canGoBack' | 'canGoForward'> {
  _navigation: HashNavigation;
  currentEntry: ReadonlySignal<RouterHistoryEntry>;
  state: ReadonlySignal<NavigationState>;
  hash: ReadonlySignal<string>;
  create: (config: SubscribeChangeConfig) => VoidFunction;
  subscribe: (callback: (update: NavigationHistoryEntry, prevLocation?: NavigationHistoryEntry | null) => void) => VoidFunction;
  navigate: (hash: string, state?: Record<string, unknown>) => NavigationResult;
  replaceState: (config?: {state?: Record<string, unknown>; hash?: string;}) => void;
  goBack: VoidFunction;
  goToPrev: VoidFunction;
  getHash: () => string;
  getState: () => NavigationState | undefined;
  hasPage: (hash?: string) => boolean;
  destroy: VoidFunction;
  getConfig: () => InitializeRouterConfig | null;
}

Router History Entry Interface

export interface RouterHistoryEntry extends NavigationHistoryEntry {
  pattern?: string;
  getParams: <T extends Record<string, string> = Record<string, string>>() => T;
  getQuery : <T extends QueryParams>() => T,
}

Configuration Interfaces

export interface InitializeRouterConfig {
  homeUrl: string;
  routeNames: string[];
}

export interface SubscribeChangeConfig {
  onChange: (loc: NavigationHistoryEntry) => void;
  config: InitializeRouterConfig;
}

Navigation History Interfaces

export type NavigationState = Record<string, unknown>;

export interface NavigationHistoryEntry {
  url: string;
  key: string;
  id: string;
  index: number;
  sameDocument: boolean;
  state?: any;
  hash: string;
}

export interface NavigationResult {
  committed: Promise<NavigationHistoryEntry>;
  finished: Promise<NavigationHistoryEntry>;
}

export interface NavigationOptions {
  state?: unknown;
  info?: unknown;
}

export interface QueryParams {
  [key: string]: string
}

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

Troubleshooting

Hash changes not being detected

  • Make sure the router is initialized before any navigation occurs
  • Check that the route names are correctly defined in your configuration

State not persisting between navigations

  • Ensure you're using navigate() with state parameter correctly
  • Verify you're retrieving state with getState() method

Route parameters not working

  • Ensure your route patterns are correctly defined in the routeNames array
  • Check that you're accessing parameters with getParams() method from the router entry

Conflicts with other routers

  • This router uses hash-based navigation, so avoid using other hash-change listeners
  • If using with another framework's router, ensure they're not both handling the same routes

License

This project is licensed under the MIT License - see the LICENSE file for details.

0.4.3

5 months ago

0.4.2

5 months ago

0.4.1

5 months ago

0.4.0

5 months ago

0.3.9

6 months ago

0.3.8

6 months ago

0.3.7

6 months ago

0.3.6

6 months ago

0.3.5

6 months ago

0.3.4

6 months ago

0.3.3

6 months ago

0.3.1

6 months ago

0.3.0

6 months ago

0.2.0

6 months ago

0.1.1

9 months ago

0.1.0

11 months ago