updateRoute - greydragon888/real-router GitHub Wiki

getRoutesApi().update

1. Overview

  • What it does: Updates configuration of an existing route without rebuilding the route tree. Allows changing forwardTo, defaultParams, decodeParams, encodeParams, canActivate, canDeactivate. Use null to remove a property.
  • When to use:
    • Dynamic redirect changes (forwardTo)
    • Updating default parameters
    • Replacing encoder/decoder functions
    • Changing guards at runtime
    • Hot reload of route configuration

2. Signature

import { getRoutesApi } from "@real-router/core/api";

const routesApi = getRoutesApi(router);

routesApi.update(name: string, updates: RouteConfigUpdate<Dependencies>): void

RouteConfigUpdate

import type { RouteConfigUpdate } from "@real-router/types";

interface RouteConfigUpdate<Dependencies> {
  forwardTo?: string | ForwardToCallback<Dependencies> | null;
  defaultParams?: Params | null;
  decodeParams?: ((params: Params) => Params) | null;
  encodeParams?: ((params: Params) => Params) | null;
  canActivate?: GuardFnFactory<Dependencies> | null;
  canDeactivate?: GuardFnFactory<Dependencies> | null;
}

Usage Examples

import { createRouter } from "@real-router/core";
import { getRoutesApi } from "@real-router/core/api";

const router = createRouter(routes);
const routesApi = getRoutesApi(router);

// Adding static forwardTo
routesApi.update("old-page", { forwardTo: "new-page" });

// Adding dynamic forwardTo
routesApi.update("landing", {
  forwardTo: (getDependency) =>
    getDependency("auth").isAdmin ? "admin" : "user",
});

// Removing forwardTo
routesApi.update("old-page", { forwardTo: null });

// Updating defaultParams
routesApi.update("users", { defaultParams: { page: 1, limit: 10 } });

// Adding decoder for parameters
routesApi.update("users.profile", {
  decodeParams: (params) => ({ ...params, id: Number(params.id) }),
});

// Adding encoder for buildPath
routesApi.update("products", {
  encodeParams: (params) => ({ ...params, id: `product-${params.id}` }),
});

// Updating activation guard
routesApi.update("admin", {
  canActivate: (router, getDependency) => (toState, fromState) => {
    return getDependency("auth").isAdmin();
  },
});

// Adding deactivation guard
routesApi.update("editor", {
  canDeactivate: (router, getDependency) => (toState, fromState) => {
    return !getDependency("formState").isDirty();
  },
});

// Removing deactivation guard
routesApi.update("editor", { canDeactivate: null });

// Multiple updates at once
routesApi.update("dashboard", {
  defaultParams: { tab: "overview" },
  canActivate: authGuardFactory,
});

3. Parameters

name (required)

  • Type: string
  • Purpose: Route name to update
  • Allowed values: Valid route name (regex ^[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$)
  • Dot-notation: Supported for nested routes
Value Behavior
"users" Updates users route
"users.profile" Updates nested users.profile
"nonexistent" Throws ReferenceError
"" Throws ReferenceError (root node)
null / undefined Throws TypeError

updates (required)

  • Type: RouteConfigUpdate<Dependencies>
  • Purpose: Object with configuration updates
  • Allowed values: Plain object (not null, not array, not primitive)

Updates Fields

Field Type Description
forwardTo string | ForwardToCallback | null Route name or callback for redirect
defaultParams Params | null Default parameters
decodeParams ((params: Params) => Params) | null Parameter decoding function
encodeParams ((params: Params) => Params) | null Parameter encoding function
canActivate GuardFnFactory<Dependencies> | null Activation guard factory
canDeactivate GuardFnFactory<Dependencies> | null Deactivation guard factory

Semantics of null

  • Passing null for any field removes the corresponding configuration
  • Absent field (or undefined) -- no-op, existing value is preserved

4. Return Value

  • Type: void

5. Side Effects

  • Config update: Modifies corresponding entries in config.forwardMap, config.forwardFnMap, config.defaultParams, config.decoders, config.encoders
  • Lifecycle update: Registers/removes canActivate and canDeactivate handlers
  • Forward cache: On forwardTo change -- revalidation and chain caching
  • matchPath/buildPath: Changes apply immediately

What is NOT changed

Component Reason
Route tree Route structure is not rebuilt
name Route name cannot be changed
path Route path cannot be changed
children Children cannot be modified via update

6. Possible Errors

Condition Error Message
Router disposed RouterError ROUTER_DISPOSED
name not string TypeError Route name must be a string
name whitespace-only TypeError must not be whitespace-only
name doesn't match pattern TypeError does not match pattern
name > 10000 characters TypeError exceeds maximum length
name is empty string ReferenceError [router.updateRoute] Invalid name: empty string. Cannot update root node.
Route doesn't exist ReferenceError [real-router] updateRoute: route "X" does not exist
updates is null TypeError [real-router] updateRoute: updates must be an object, got null
updates is primitive TypeError [real-router] updateRoute: updates must be an object, got {type}
updates is array TypeError [real-router] updateRoute: updates must be an object, got array
forwardTo wrong type TypeError [real-router] updateRoute: forwardTo must be a string, function, or null, got {type}
defaultParams not object TypeError [real-router] updateRoute: defaultParams must be an object or null, got {type}
decodeParams not function TypeError [real-router] updateRoute: decodeParams must be a function or null, got {type}
encodeParams not function TypeError [real-router] updateRoute: encodeParams must be a function or null, got {type}
decodeParams is async TypeError [real-router] updateRoute: decodeParams cannot be an async function
encodeParams is async TypeError [real-router] updateRoute: encodeParams cannot be an async function
forwardTo callback is async TypeError [real-router] updateRoute: forwardTo callback cannot be an async function
forwardTo target doesn't exist Error [real-router] updateRoute: forwardTo target "X" does not exist
forwardTo creates cycle Error Circular forwardTo: A -> B -> A
forwardTo chain > 100 Error forwardTo chain exceeds maximum depth (100): ...
forwardTo target requires unavailable params Error [real-router] forwardTo target "X" requires params [Y] that are not available in source "Z"

Warning (non-blocking)

Condition Action
Navigation in progress console.error(), update continues

Error Examples

import { createRouter } from "@real-router/core";
import { getRoutesApi } from "@real-router/core/api";

const router = createRouter(routes);
const routesApi = getRoutesApi(router);

// Validation errors
routesApi.update(123 as any, {}); // TypeError: not a string
routesApi.update("nonexistent", {}); // ReferenceError: does not exist
routesApi.update("route", null as any); // TypeError: must be an object
routesApi.update("route", [] as any); // TypeError: got array

// forwardTo errors
routesApi.update("a", { forwardTo: "nonexistent" }); // Error: target does not exist
routesApi.update("a", { forwardTo: "a" }); // Error: Circular forwardTo

// Transformer function errors
routesApi.update("r", { decodeParams: "string" as any }); // TypeError: must be a function
routesApi.update("r", { decodeParams: async (p) => p }); // TypeError: cannot be async

// Router disposed
router.dispose();
routesApi.update("home", {}); // RouterError: ROUTER_DISPOSED

7. Related Methods

Method When to use
getRoutesApi().add Adding new routes
getRoutesApi().remove Removing a route
getRoutesApi().clear Removing all routes
getRoutesApi().get Getting current route definition
getRoutesApi().has Checking route existence
getLifecycleApi().addActivateGuard Direct canActivate handler registration
getLifecycleApi().addDeactivateGuard Direct canDeactivate handler registration

8. Behavior

forwardTo

import { createRouter } from "@real-router/core";
import { getRoutesApi, getPluginApi } from "@real-router/core/api";

const router = createRouter([
  { name: "source", path: "/source" },
  { name: "target", path: "/target" },
  { name: "target2", path: "/target2" },
]);
const routesApi = getRoutesApi(router);
const pluginApi = getPluginApi(router);

// Adding forwardTo
routesApi.update("source", { forwardTo: "target" });

// Updating existing forwardTo
routesApi.update("source", { forwardTo: "target2" });

// Removing forwardTo via null
routesApi.update("source", { forwardTo: null });

// Works with matchPath after update
routesApi.update("source", { forwardTo: "target" });
const state = pluginApi.matchPath("/source");
expect(state?.name).toBe("target"); // Redirect applied

Dynamic forwardTo

// Switching from static to dynamic
routesApi.update("landing", {
  forwardTo: (getDependency) => getDependency("config").target,
});

// Switching from dynamic to static
routesApi.update("landing", { forwardTo: "dashboard" });

// Switching to null clears both static and dynamic
routesApi.update("landing", { forwardTo: null });

// Dynamic forwardTo skips target/cycle validation at update time
// (validated at navigation time instead)
routesApi.update("landing", {
  forwardTo: (getDep) => getDep("config").target,
});
// No error even if "config" dependency target doesn't exist yet

Cycle Detection

// Direct cycle (A -> A)
expect(() => routesApi.update("a", { forwardTo: "a" })).toThrowError(
  /Circular forwardTo/,
);

// Indirect cycle (A -> B -> C -> A)
routesApi.update("a", { forwardTo: "b" });
routesApi.update("b", { forwardTo: "c" });
expect(() => routesApi.update("c", { forwardTo: "a" })).toThrowError(
  /Circular forwardTo/,
);

defaultParams

// Adding
routesApi.update("users", { defaultParams: { page: 1, limit: 10 } });

// Updating (full replacement, not merge)
routesApi.update("users", { defaultParams: { page: 2 } });
// limit is NOT preserved!

// Removing via null
routesApi.update("users", { defaultParams: null });

decodeParams

// Adding decoder
const decoder = (params) => ({ ...params, id: Number(params.id) });
routesApi.update("items", { decodeParams: decoder });

// Usage in matchPath
const state = pluginApi.matchPath("/items/123");
expect(state?.params.id).toBe(123); // number, not string
expect(typeof state?.params.id).toBe("number");

// Removing via null
routesApi.update("items", { decodeParams: null });

encodeParams

// Adding encoder
const encoder = (params) => ({ ...params, id: `user-${params.id}` });
routesApi.update("users", { encodeParams: encoder });

// Usage in buildPath
const path = router.buildPath("users", { id: "123" });
expect(path).toBe("/users/user-123");

// Removing via null
routesApi.update("users", { encodeParams: null });

canActivate

// Adding guard
const guardFactory = () => () => true;
routesApi.update("secure", { canActivate: guardFactory });
// Guard will be checked during navigation

// Updating guard (blocks navigation)
const blockingGuard = () => () => false;
routesApi.update("secure", { canActivate: blockingGuard });

// Navigation to 'secure' will now be blocked
try {
  await router.navigate("secure");
} catch (err) {
  expect(err?.code).toBe("CANNOT_ACTIVATE");
}

// Removing via null
routesApi.update("secure", { canActivate: null });
// Navigation now allowed

canDeactivate

// Adding guard
const guardFactory = () => () => false;
routesApi.update("editor", { canDeactivate: guardFactory });

// Navigation away from 'editor' will now be blocked
await router.navigate("editor");
try {
  await router.navigate("home");
} catch (err) {
  expect(err?.code).toBe("CANNOT_DEACTIVATE");
}

// Replacing guard
const newGuard = () => () => true;
routesApi.update("editor", { canDeactivate: newGuard });
// Navigation now allowed

// Removing via null
routesApi.update("editor", { canDeactivate: null });
// Guard cleared, navigation allowed

Atomicity

// If validation fails -- nothing is applied
expect(() =>
  routesApi.update("route", {
    forwardTo: "target",
    defaultParams: "invalid" as any, // TypeError
  }),
).toThrowError(/defaultParams must be an object/);

// forwardTo was NOT applied

Edge Cases

// Empty object -- no-op
expect(() => routesApi.update("route", {})).not.toThrowError();

// Missing fields -- existing values preserved
routesApi.update("preserve", {});
// defaultParams, decoders, encoders etc. remain unchanged

// Replacement, not merge
routesApi.update("seq", { defaultParams: { a: 1, b: 2 } });
routesApi.update("seq", { defaultParams: { c: 3 } });
// Result: { c: 3 } -- a, b are lost

Guarantees

  • Changes are applied atomically (all or nothing)
  • On validation error -- config is not changed
  • null removes property, undefined -- no-op
  • Updates apply immediately (affect matchPath/buildPath)
  • forwardTo chains are validated for cycles and depth
  • Async functions for decoder/encoder/forwardTo callback are forbidden
  • Route structure (name, path, children) is immutable

Migration from router5

New method. Absent in master branch.

Analog in master

In master version there was no ability to update route configuration after adding. Had to use workarounds:

// Master: direct config access (fragile, undocumented)
router.config.defaultParams["users"] = { page: 1 };
router.config.decoders["users"] = (p) => ({ ...p, id: Number(p.id) });
router.config.encoders["users"] = (p) => ({ ...p, slug: encode(p.slug) });
router.config.forwardMap["old"] = "new";

// Master: for canActivate -- separate method
router.canActivate("users", guardFactory);

// Master: removal -- setting undefined or delete
delete router.config.defaultParams["users"];
router.config.forwardMap["old"] = undefined;

Problems with master approach:

Problem Description
No public API Direct access to router.config -- internal structure
No validation forwardTo could reference non-existent route
No cycle check Could create A -> B -> A cycle
No params check forwardTo could require unavailable parameters
Scattered operations Each property -- separate operation
No atomicity Partial updates on error
No forwardMap cache cleanup After forwardMap change cache wasn't updated
Async functions Could set async decoder/encoder (breaks matchPath/buildPath)

Advantages of New Method

Aspect Master (no method) Current (getRoutesApi().update)
API Internal config access Explicit standalone function
Route validation No Existence check
forwardTo validation No Target exists, no cycles, params compatible
Type validation No Full type validation
Async detection No Async decoder/encoder/forwardTo forbidden
Atomicity No All or nothing
Property removal delete / undefined null semantics
Cache invalidation Manual Automatic
TypeScript No Full typing

Breaking Changes

Severity What Changed Was Now Impact
CRITICAL API access router.config.* getRoutesApi(router).update() Change all call sites
HIGH Return value None (direct assign) void No chaining
HIGH Validation None Full validation Invalid data breaks

Summary

Category Status
Breaking Changes None (new method)
Public API Added
Validation Full
Atomicity All or nothing
TypeScript Full typing
Cache management Automatic update