Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 125 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,34 @@ Each node requires a `RouteData` object to describe it.
```js
import RouteData from '@jack-henry/web-component-router/lib/route-data.js';
/**
* @callback BeforeEnterFunction
* @param {!Object} currentNode - The current route node.
* @param {(Object|undefined)} nextNodeIfExists - The next route node, if one exists.
* @param {string} routeId - The ID of the route being entered.
* @param {!Object} context - A context object, potentially containing shared state or utilities.
* @returns {Promise<boolean|void>} Should return a Promise.
* Resolves to `true` or `void` to allow navigation.
* Resolves to `false` to prevent navigation.
*
* Defines the signature for the beforeEnter lifecycle hook.
* @callback RouteEnterFunction
* @param {!Object} currentNode - The current route node.
* @param {(Object|undefined)} nextNodeIfExists - The next route node, if one exists.
* @param {string} routeId - The ID of the route being entered.
* @param {!Object} context - A context object, potentially containing shared state or utilities.
* @returns {Promise<boolean|void>} Should return a Promise.
* Resolves to `true` or `void` to allow navigation.
Copy link

@C-Duxbury C-Duxbury May 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it semantically make sense to allow the routEnter() callback to have a return value? My mental model is that the transition has already occurred by the time that hook is called so the window to abort would have passed.

I also notice that we don't have symmetry between the exit and enter hooks (exit lacks a "before" flavor). Could these just be collapsed into one enter and one exit (the way the mixin does) and have them return boolean values to indicate if the transition can occur?

Copy link
Contributor Author

@matt-sneed matt-sneed May 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@C-Duxbury i was just recreating the same logic that was currently there, but for the routeData version. i believe the intention is to prevent recursing down the node list so that if a parent returned false in it's lifecycle method it would prevent further tree navigation.

const shouldContinue = await routingElem.routeEnter(currentEntryNode, nextEntryNode, routeId, context);

Copy link

@C-Duxbury C-Duxbury May 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I edited my comment to expand on this. Should we just have a single "enter" hook that occurs before and can return a boolean value to indicate if the transition can occur? I'm wondering if there's value in having distinct before/after hooks for route enter since the mixin doesn't.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't think of a use case that needs both unless there is some case for performing since actions after committing to entering a route. Seems reasonable to have just the single case. @matt-sneed ?

Copy link
Contributor

@jrobinson01 jrobinson01 May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this is what I'm picturing:

entry

if routeData.beforeEnter(...) !== false
  get or create route component
     if routeComponent.routeEnter
       routeComponent.routeEnter(...)

exit

if routeData.beforeExit(...) !== false
  get exit component
    if routeComponent.routeExit
      routeComponent.routeExit(...)

So the RouteData in the config only provides the beforeEnter and/or beforeExit hooks. The components can still implement routeEnter and/or routeExit but are not required to do so.

Copy link

@C-Duxbury C-Duxbury May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i might use routeEnter to navigate my page down to a anchor on the page based on the route path

@matt-sneed Do we need router-specific hooks to satisfy this use case? My thought is that connectedCallback() or firstUpdated() could be used for this purpose. The component doesn't need to have knowledge about routing to do something when it's placed into the DOM.

I think we can probably get away with just having one enter and exit hook each in the config to allow actions/guards that happen before the transition occurs and the element is placed in the DOM.

Copy link
Contributor Author

@matt-sneed matt-sneed May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example, the router config shouldn't be directly using fetch and updating data, nor should it be manipulating the DOM

@jrobinson01 agree with that statement, i'm saying that the router provides the "hook" to let the app do that job. now part of the equation is what lifecycle hooks are needed to do all the use cases we might need.

Do we need router-specific hooks to satisfy this use case? My thought is that connectedCallback() or firstUpdated() could be used for this purpose. The component doesn't need to have knowledge about routing to do something when it's placed into the DOM.

I think we can probably get away with just having one enter and exit hook each in the config to allow actions/guards that happen before the transition occurs and the element is placed in the DOM.

@C-Duxbury here's a couple pointed examples of where that may not work, and using a routeEnter in a component is still needed:

  override async routeEnter(
    currentNode: RouteTreeNode,
    nextNodeIfExists: RouteTreeNode,
    routeId: string,
    context: Context,
  ): Promise<boolean | undefined> {
    // store path so it can be passed to sidebar
    this.routePath = context.path;
    await super.routeEnter(currentNode, nextNodeIfExists, routeId, context);
    if (context.hash) {
      await this.navToSection(`#${context.hash}`);
    }
    return true;
  }
  override async routeEnter(
    currentNode: RouteTreeNode,
    nextNodeIfExists: RouteTreeNode,
    routeId: string,
    context: Context,
  ) {
    // store path so it can be passed to sidebar
    await super.routeEnter(currentNode, nextNodeIfExists, routeId, context);
    this.reversalFlag = context.query.has('reversalFlag');
  }

Copy link

@C-Duxbury C-Duxbury May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@matt-sneed We'd need a way to get at the route context directly from the Router instance to support this use case without hooks. You'd basically just move the logic from routerEnter() into firstUpdated():

protected firstUpdated() {
    const { hash } = Router.getInstance().getContext();
    if (hash) {
      await this.navToSection(`#${hash}`);
    }
}

This is one of the gaps I mentioned in the comparison document. Getting the context outside of hooks would be a separate enhancement PR, but I think it's orthogonal with the goal here of providing ways to route without inheritance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, i think if we had access to the current route context in some consumable way at any point in the lifecycle, it does change the requirements

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The router currently reuses components that are already in the tree, so for an existing component, the router would need to have changed one of the component's properties in order to trigger the lit lifecycle events. That might be sufficient for most (all?) use cases where a component needs to react to a route change but maybe worth pointing out here.

* Resolves to `false` to prevent navigation.

* @callback RouteExitFunction
* @param {!Object} currentNode - The current route node.
* @param {(Object|undefined)} nextNode - The next route node, if one exists.
* @param {string} routeId - The ID of the route being exited.
* @param {!Object} context - A context object, potentially containing shared state or utilities.
* @returns {Promise<boolean|void>} Should return a Promise.
* Resolves to `true` or `void` to allow navigation.
* Resolves to `false` to prevent navigation.
*
* @param {string} name of this route. Must be unique.
* @param {string} tagName of the element. Case insensitive.
* @param {string} path of this route (express style).
Expand All @@ -45,7 +73,9 @@ import RouteData from '@jack-henry/web-component-router/lib/route-data.js';
* These should match to named path segments. Each camel case name
* is converted to a hyphenated name to be assigned to the element.
* @param {boolean=} requiresAuthentication (optional - defaults true)
* @param {function():Promise=} beforeEnter Optionally allows you to dynamically import the component for a given route. The route-mixin.js will call your beforeEnter on routeEnter if the component does not exist in the dom.
* @param {BeforeEnterFunction=} beforeEnter Optionally allows you to execute functionality before routeEnter such as importing the component for a given route. The router will call the beforeEnter on each node that has one defined.
* @param {RouteEnterFunction=} routeEnter Optionally allows you to execute functionality when the route is entered, after the element has been created and attached.
* @param {RouteExitFunction=} routeExit Optionally allows you to execute functionality when navigating away from this route.
*/
const routeData = new RouteData(
'Name of this route',
Expand Down Expand Up @@ -108,36 +138,72 @@ export default app;

### Defining a route configuration in the Router's constructor

Alternatively you can pass a `routeConfig` object when instantiating your router. This will use the `RouteTreeNode` and `RouteData` to create your applications routeTree.
Alternatively you can pass a `routeConfig` object when instantiating your router. This will use the `RouteTreeNode` and `RouteData` to create your applications routeTree. You can define the route lifecycle methods beforeEnter, routeEnter, and routeExit in both the RouteConfig and if using the RouterMixin as overriden methods

**Example RouteConfig object**
```
const routeConfig = {
id: 'app',
tagName: 'APP-MAIN',
path: '',
beforeEnter: (currentNode, nextNodeIfExists, routeId, context) => {
await setupServices();
}
routeEnter: (currentNode, nextNodeIfExists, routeId, context) => {
doSomething();
}
routeExit: (currentNode, nextNode, routeId, context) => {
teardownServices();
}
subRoutes: [{
id: 'app-user',
tagName: 'APP-USER-PAGE',
path: '/users/:userId([0-9]{1,6})',
params: ['userId'],
beforeEnter: () => import('../app-user-page.js')
beforeEnter: (currentNode, nextNodeIfExists, routeId, context) => {
isAuthorized();
import('../app-user-page.js')
}
routeEnter: (currentNode, nextNodeIfExists, routeId, context) => {
doSomething();
}
routeExit: (currentNode, nextNode, routeId, context) => {
saveDataToCache();
}
}, {
id: 'app-user-account',
tagName: 'APP-ACCOUNT-PAGE',
path: '/users/:userId([0-9]{1,6})/accounts/:accountId([0-9]{1,6})',
params: ['userId', 'accountId'],
beforeEnter: () => import('../app-account-page.js')
beforeEnter: (currentNode, nextNodeIfExists, routeId, context) => {
isAuthorized();
import('../app-account-page.js')
}
routeEnter: (currentNode, nextNodeIfExists, routeId, context) => {
doSomething();
}
routeExit: (currentNode, nextNode, routeId, context) => {
saveDataToCache();
}
}, {
id: 'app-about',
tagName: 'APP-ABOUT',
path: '/about',
authenticated: false,
beforeEnter: () => import('../app-about.js')
beforeEnter: (currentNode, nextNodeIfExists, routeId, context) => {
isAuthorized();
import('../app-about.js);
}
routeEnter: (currentNode, nextNodeIfExists, routeId, context) => {
doSomething();
}
routeExit: (currentNode, nextNode, routeId, context) => {
saveDataToCache();
}
}]
};

const router = New Router(routeConfig);
const router = new Router(routeConfig);
```

When using this method the default is that a route requires authentication, as shown above in the 'about' route, set `authenticated` to false to create a route which does not require authentication.
Expand Down Expand Up @@ -209,6 +275,22 @@ class MyElement extends HtmlElement {
}
currentNode.getValue().element = undefined;
}

/**
* Implementation for the callback on before entering a route node.
* beforeEnter is called for EVERY route change.
*
* @param {!RouteTreeNode} currentNode
* @param {!RouteTreeNode|undefined} nextNodeIfExists - the
* child node of this route.
* @param {string} routeId - unique name of the route
* @param {!Context} context - page.js Context object
* @return {!Promise<boolean=>}
*/
async beforeEnter(currentNode, nextNodeIfExists, routeId, context)
{

}
}
```

Expand All @@ -234,6 +316,35 @@ import animatedRouteMixin from '@jack-henry/web-component-router/animated-routin
class MyElement extends animatedRouteMixin(HTMLElement, 'className') { }
```

## Lifecycle Hook Execution Order

The `@jack-henry/web-component-router` allows you to define lifecycle hooks at two levels:

1. **Route Configuration:** Within the `RouteData` object or the equivalent properties in the route config object (`beforeEnter`, `routeEnter`, `routeExit`).
2. **Component Methods:** By overriding the corresponding methods (`beforeEnter`, `routeEnter`, `routeExit`) in your web component, especially when using the `routingMixin`.

When navigating between routes, the router executes these hooks in a specific, hierarchical order for each node in the route tree that is part of the transition (either entering or exiting).

**During Route Entry:**

When entering a new route, the following hooks are executed sequentially for each relevant node, starting from the top of the route tree down to the target node:

1. `beforeEnter` function defined in the **Route Configuration** (`RouteData` or config object).
2. `beforeEnter` method overridden on the **Component Instance**.
3. `routeEnter` function defined in the **Route Configuration** (`RouteData` or config object).
4. `routeEnter` method overridden on the **Component Instance**.

**During Route Exit:**

When exiting a route, the following hooks are executed sequentially for each relevant node, starting from the node being exited up towards the common ancestor node:

1. `routeExit` method overridden on the **Component Instance**.
2. `routeExit` function defined in the **Route Configuration** (`RouteData` or config object).

This hierarchical execution allows you to perform actions specific to the route definition first (e.g., data fetching or component import in `beforeEnter` config), followed by component-specific logic in the overridden methods.

Hooks can be defined in one or both locations. If a hook is defined in both the route configuration and the component, both will be called in the order specified above. If defined in only one location, only that definition will be executed for that specific hook and node.

## Root App Element

The routing configuration is typically defined inside the main app element
Expand All @@ -245,9 +356,9 @@ The root element typically has a slightly different configuration.
import myAppRouteTree from './route-tree.js';
import router, {Context, routingMixin} from '@jack-henry/web-component-router';

class AppElement extends routingMixin(Polymer.Element) {
class AppElement extends routingMixin(LitElement) {
static get is() { return 'app-element'; }

isAuthenticated = false;
connectedCallback() {
super.connectedCallback();

Expand All @@ -258,11 +369,14 @@ class AppElement extends routingMixin(Polymer.Element) {
// Start routing
router.start();
}

async beforeEnter(currentNode, nextNodeIfExists, routeId, context) {
//Fetch authentication
this.isAuthenticated = true;
}
async routeEnter(currentNode, nextNodeIfExists, routeId, context) {
context.handled = true;
const destinationNode = router.routeTree.getNodeByKey(routeId);
if (isAuthenticated || !destinationNode.requiresAuthentication()) {
if (this.isAuthenticated || !destinationNode.requiresAuthentication()) {
// carry on. user is authenticated or doesn't need to be.
return super.routeEnter(currentNode, nextNodeIfExists, routeId, context);
}
Expand Down Expand Up @@ -296,7 +410,7 @@ issue.
import myAppRouteTree from './route-tree.js';
import router, {routingMixin} from '@jack-henry/web-component-router';

class AppElement extends routingMixin(Polymer.Element) {
class AppElement extends routingMixin(LitElement) {
static get is() { return 'app-element'; }

connectedCallback() {
Expand Down
62 changes: 58 additions & 4 deletions lib/route-data.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
/** @fileoverview Basic data for a route */

/**
* @callback BeforeEnterFunction
* @param {!Object} currentNode - The current route node.
* @param {(Object|undefined)} nextNodeIfExists - The next route node, if one exists.
* @param {string} routeId - The ID of the route being entered.
* @param {!Object} context - A context object, potentially containing shared state or utilities.
* @returns {Promise<boolean|void>} Should return a Promise.
* Resolves to `true` or `void` to allow navigation.
* Resolves to `false` to prevent navigation.
*
* Defines the signature for the beforeEnter lifecycle hook.
*/
/**
* @callback RouteEnterFunction
* @param {!Object} currentNode - The current route node.
* @param {(Object|undefined)} nextNodeIfExists - The next route node, if one exists.
* @param {string} routeId - The ID of the route being entered.
* @param {!Object} context - A context object, potentially containing shared state or utilities.
* @returns {Promise<boolean|void>} Should return a Promise.
* Resolves to `true` or `void` to allow navigation.
* Resolves to `false` to prevent navigation.
*
* Defines the signature for the routeEnter lifecycle hook.
*/
/**
* @callback RouteExitFunction
* @param {!Object} currentNode - The current route node.
* @param {(Object|undefined)} nextNode - The next route node, if one exists.
* @param {string} routeId - The ID of the route being entered.
* @param {!Object} context - A context object, potentially containing shared state or utilities.
* @returns {Promise<boolean|void>} Should return a Promise.
* Resolves to `true` or `void` to allow navigation.
* Resolves to `false` to prevent navigation.
*
* Defines the signature for the routeEnter lifecycle hook.
*/
class RouteData {
/**
* @param {string} id of this route
Expand All @@ -8,9 +43,12 @@ class RouteData {
* @param {!Array<string>=} namedParameters list in camelCase. Will be
* converted to a map of camelCase and hyphenated.
* @param {boolean=} requiresAuthentication
* @param {function():Promise=} beforeEnter
* @param {BeforeEnterFunction=} beforeEnter Function to execute before the route is entered.
* @param {RouteEnterFunction=} routeEnter Function to execute when the route is entered.
* @param {RouteExitFunction=} routeExit Function to execute when the route is exited.
*/
constructor(id, tagName, path, namedParameters, requiresAuthentication, beforeEnter) {

constructor(id, tagName, path, namedParameters, requiresAuthentication, beforeEnter, routeEnter, routeExit) {
namedParameters = namedParameters || [];
/** @type {!Object<string, string>} */
const params = {};
Expand All @@ -30,7 +68,23 @@ class RouteData {
this.element = undefined;
this.requiresAuthentication = requiresAuthentication !== false;

this.beforeEnter = beforeEnter || (() => Promise.resolve());
/**
* The function to execute before entering the route.
* @type {BeforeEnterFunction}
*/
this.beforeEnter = beforeEnter || ((currentNode, nextNodeIfExists, routeId, context) => Promise.resolve());

/**
* The function to execute before entering the route.
* @type {RouteExitFunction}
*/
this.routeEnter = routeEnter || ((currentNode, nextNodeIfExists, routeId, context) => Promise.resolve());

/**
* The function to execute before entering the route.
* @type {RouteExitFunction}
*/
this.routeExit = routeExit || ((currentNode, nextNode, routeId, context) => Promise.resolve());
}
}

Expand Down
Loading