10000 ✨ Custom router event target by jsimck · Pull Request #606 · seznam/ima · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content
8000

✨ Custom router event target #606

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions .changeset/violet-geckos-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@ima/storybook-integration": patch
"@ima/core": minor
---

Added support for custom event targets in Router's listen methods. This enables better control over routing behavior by allowing you to:
- Scope navigation handling to specific parts of your application
- Handle multiple independent routed sections on a page
- Better integrate IMA.js routing into existing applications

Changes:
- Modified `listen(target?: EventTarget)` method to allow for optional target
- Modified `unlisten(target?: EventTarget)` method to allow for optional target
- Added new `unlistenAll()` method to cleanup all event listeners at once
45 changes: 43 additions & 2 deletions docs/basic-features/routing/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ add(name, pathExpression, controller, view, options = undefined);

### name

> `string`
> `string`

This argument represents **unique route name**. You can use this name when [linking between routes](./introduction.md#linking-between-routes) or getting the `route` instance using `getRouteHandler()` method.

### pathExpression

> `string | object`
> `string | object`

This can be either `object` for [dynamic routes](./dynamic-routes.md) or `string` representing route path. The pathExpression supports **[parameter substitutions](./introduction.md#route-params-substitutions)

Expand Down Expand Up @@ -356,3 +356,44 @@ Custom redirect http status code.
> `object = undefined`

Custom response headers.

## Custom client router listener root element

By default, the router listens for navigation events (clicks on links and browser history changes) on the window object. However, you can also specify custom elements to listen on within the initialization function in `main.js`:

```javascript
const router = oc.get('$Router');
Copy link
Contributor

Choose a reason for hiding this comment

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

Příklad je chybný, protože oc a document nejsou v main.js dostupné.

const appRoot = document.querySelector('#my-app-root');

// Listen for navigation events within specific elements
ima
.onLoad()
.then(() => {
ima.reviveClientApp(getInitialAppConfigFunctions(), appRoot);
Copy link
Contributor

Choose a reason for hiding this comment

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

const appRoot = document.querySelector('#my-app-root');

ima.reviveClientApp(getInitialAppConfigFunctions(), appRoot);

})
.catch(error => {
if ($Debug && typeof window !== 'undefined') {
window.__IMA_HMR?.emitter?.emit('error', { error });
console.error(error);
}
});

// ...
// Stop listening on a specific element
router.unlisten(appRoot);

// Stop listening on all elements
router.unlistenAll();
```

This is particularly useful when you want to:
- Scope navigation handling to specific parts of your application
- Have multiple independent routed sections on a page
- Integrate IMA.js routing into an existing application
- Handle routing in modals or other isolated UI components

The router will handle clicks on links (`<a>` elements) and popstate events within the specified elements, while ignoring navigation events outside of them. You can add multiple listener roots and manage them independently.

:::info
When cleaning up your application (for example during unmounting) in a non-standard way, make sure to call `unlistenAll()` to properly remove all event listeners. In the default state the `unlistenAll()` method is called automatically when the instance is destroyed.
:::
18 changes: 11 additions & 7 deletions packages/core/src/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,14 +266,17 @@ export function bootClientApp(
return app;
}

export function routeClientApp(app: {
bootstrap: Bootstrap;
oc: ObjectContainer;
}) {
export function routeClientApp(
app: {
bootstrap: Bootstrap;
oc: ObjectContainer;
},
routerRoot?: EventTarget
) {
const router = app.oc.get('$Router');

return router
.listen()
.listen(routerRoot)
.route(router.getPath())
.catch((error: GenericError) => {
if (typeof $IMA.fatalErrorHandler === 'function') {
Expand All @@ -287,7 +290,8 @@ export function routeClientApp(app: {
}

export async function reviveClientApp(
initialAppConfigFunctions: InitAppConfig
initialAppConfigFunctions: InitAppConfig,
routerRoot?: EventTarget
) {
await autoYield();
const root = _getRoot();
Expand All @@ -302,7 +306,7 @@ export async function reviveClientApp(
app = bootClientApp(app, bootConfig);

await autoYield();
return routeClientApp(app).then(pageInfo => {
return routeClientApp(app, routerRoot).then(pageInfo => {
return Object.assign({}, pageInfo || {}, { app, bootConfig });
});
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/config/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const initServices: InitServicesFunction = (ns, oc, config) => {
window.__IMA_HMR?.emitter?.once('destroy', async () => {
oc.get('$Dispatcher').clear();
oc.get('$Observable').destroy();
oc.get('$Router').unlisten();
oc.get('$Router').unlistenAll();
oc.get('$PageRenderer').unmount();
await oc.get('$PageManager').destroy();
});
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/router/AbstractRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,13 @@ export abstract class AbstractRouter extends Router {
);
}

/**
* @inheritDoc
*/
unlistenAll() {
return this;
}

/**
* @inheritDoc
*/
Expand Down
63 changes: 47 additions & 16 deletions packages/core/src/router/ClientRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export class ClientRouter extends AbstractRouter {
protected _boundHandlePopState = (event: Event) =>
this._handlePopState(event as PopStateEvent);

protected _routerRoots: EventTarget[] = [];

/**
* Mounted promise to prevent routing until app is fully mounted.
*/
Expand Down Expand Up @@ -124,17 +126,21 @@ export class ClientRouter extends AbstractRouter {
/**
* @inheritDoc
*/
listen() {
const nativeWindow = this._window.getWindow();
listen(target?: EventTarget) {
const eventTarget = target ?? this._window.getWindow()!;

if (target) {
this._routerRoots.push(eventTarget);
}

this._window.bindEventListener(
nativeWindow as EventTarget,
eventTarget,
Events.POP_STATE,
this._boundHandlePopState
);

this._window.bindEventListener(
nativeWindow as EventTarget,
eventTarget,
Events.CLICK,
this._boundHandleClick
);
Expand All @@ -145,20 +151,45 @@ export class ClientRouter extends AbstractRouter {
/**
* @inheritDoc
*/
unlisten() {
const nativeWindow = this._window.getWindow();
unlisten(target?: EventTarget) {
const eventTarget = target ?? this._window.getWindow()!;

try {
this._window.unbindEventListener(
eventTarget,
Events.POP_STATE,
this._boundHandlePopState
);

this._window.unbindEventListener(
nativeWindow as EventTarget,
Events.POP_STATE,
this._boundHandlePopState
);
this._window.unbindEventListener(
eventTarget,
Events.CLICK,
this._boundHandleClick
);
} catch (error) {
if ($Debug) {
console.warn('Failed to unbind router events:', error);
}
}

this._window.unbindEventListener(
nativeWindow as EventTarget,
Events.CLICK,
this._boundHandleClick
);
// Clear the reference
if (target) {
this._routerRoots = this._routerRoots.filter(
root => root !== eventTarget
);
}

return this;
}

/**
* @inheritDoc
*/
unlistenAll(): this {
this._routerRoots.forEach(root => this.unlisten(root));

// We also need to call unlisten on the window object
this.unlisten();

return this;
}
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/router/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ export abstract class Router {
*
* @return This router.
*/
listen() {
listen(routerRoot?: EventTarget) {
return this;
}

Expand All @@ -286,6 +286,13 @@ export abstract class Router {
return this;
}

/**
* Handles the cleanup and unregisters all registered router listeners.
*/
unlistenAll() {
return this;
}

/**
* Redirects the client to the specified location.
*
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/router/ServerRouter.ts
< 10000 td id="diff-d076bf06c8ef6933e73fe2b662a1b2126532ab0d656e28bc2528ffb474725fd9R84" data-line-number="84" class="blob-num blob-num-context js-linkable-line-number js-blob-rnum">
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ export class ServerRouter extends AbstractRouter {
return this;
}

/**
* @inheritDoc
*/
unlistenAll() {
return this;
}

/**
* @inheritDoc
*/
Expand Down
93 changes: 93 additions & 0 deletions packages/core/src/router/__tests__/ClientRouterSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,99 @@ describe('ima.core.router.ClientRouter', () => {
expect(window.bindEventListener).toHaveBeenCalledTimes(2);
});

it('should add listener to popState event, click event on custom element', () => {
jest.spyOn(window, 'bindEventListener').mockImplementation();
const customElement = document.createElement('div');

router.listen(customElement);

expect(window.bindEventListener).toHaveBeenCalledTimes(2);
expect(window.bindEventListener).toHaveBeenCalledWith(
customElement,
'popstate',
expect.any(Function)
);
expect(window.bindEventListener).toHaveBeenCalledWith(
customElement,
'click',
expect.any(Function)
);
});

it('should store custom element reference in routerRoots array', () => {
const customElement = document.createElement('div');
router.listen(customElement);

expect(router['_routerRoots']).toContain(customElement);
});

it('should store multiple custom elements in routerRoots array', () => {
const customElement1 = document.createElement('div');
const customElement2 = document.createElement('div');

router.listen(customElement1);
router.listen(customElement2);

expect(router['_routerRoots']).toContain(customElement1);
expect(router['_routerRoots']).toContain(customElement2);
expect(router['_routerRoots']).toHaveLength(2);
});

it('should cleanup listeners from specific custom element', () => {
jest.spyOn(window, 'unbindEventListener').mockImplementation();
const customElement1 = document.createElement('div');
const customElement2 = document.createElement('div');

router.listen(customElement1);
router.listen(customElement2);
router.unlisten(customElement1);

expect(window.unbindEventListener).toHaveBeenCalledWith(
customElement1,
'popstate',
expect.any(Function)
);
expect(window.unbindEventListener).toHaveBeenCalledWith(
customElement1,
'click',
expect.any(Function)
);
expect(router['_routerRoots']).not.toContain(customElement1);
expect(router['_routerRoots']).toContain(customElement2);
});

it('should cleanup all listeners with unlistenAll', () => {
jest.spyOn(window, 'unbindEventListener').mockImplementation();
const customElement1 = document.createElement('div');
const customElement2 = document.createElement('div');

router.listen(customElement1);
router.listen(customElement2);
router.unlistenAll();

expect(window.unbindEventListener).toHaveBeenCalledWith(
customElement1,
'popstate',
expect.any(Function)
);
expect(window.unbindEventListener).toHaveBeenCalledWith(
customElement1,
'click',
expect.any(Function)
); 57AE
expect(window.unbindEventListener).toHaveBeenCalledWith(
customElement2,
'popstate',
expect.any(Function)
);
expect(window.unbindEventListener).toHaveBeenCalledWith(
customElement2,
'click',
expect.any(Function)
);
expect(router['_routerRoots']).toHaveLength(0);
});

it('should remove listener to popState event, click event', () => {
jest.spyOn(window, 'unbindEventListener').mockImplementation();

Expand Down
2 changes: 1 addition & 1 deletion packages/storybook-integration/src/loaders/imaLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ async function destroyInstance(

app.oc.get('$Dispatcher').clear();
app.oc.get('$Observable').destroy();
app.oc.get('$Router').unlisten();
app.oc.get('$Router').unlistenAll();
app.oc.get('$PageRenderer').unmount();
await app.oc.get('$PageManager').destroy();
}
Expand Down
Loading
Loading
0