Repository: aurelia/aurelia
Pull request #2126: fix(router-lite): support for conventional HTML-only component
Author: login: Sayan751
Created: 2025-03-01T09:34:28Z | Merged: 2025-03-03T14:01:35Z

<!---
Thanks for filing a pull request 😄 ! Before you submit, please read the following:

Search open/closed issues before submitting since someone might have pushed the same thing before!
-->

# Pull Request

## 📖 Description

<!---
Provide some background and a description of your work.
-->

This PR adds support for routing to conventional HTML-only components.

### 🎫 Issues

<!---
* List and link relevant issues here.
-->

The issue is reported in [discourse](https://discourse.aurelia.io/t/router-questions/6360/3). Thanks @elmt1 for reporting this issue!

## 👩‍💻 Reviewer Notes

<!---
Provide some notes for reviewers to help them provide targeted feedback.
-->

## 📑 Test Plan

<!---
Please provide a summary of the tests affected by this work and any unique strategies employed in testing the features/fixes.
-->

## ⏭ Next Steps

<!---
If there is relevant follow-up work to this PR, please list any existing issues or provide brief descriptions of what you would like to do next.
-->

<!--
Love Aurelia? Please consider supporting our collective:
👉  https://opencollective.com/aurelia
-->



Diff:
diff --git a/docs/user-docs/router-lite/configuring-routes.md b/docs/user-docs/router-lite/configuring-routes.md
index fcd688dcdf..0d44ee60d5 100644
--- a/docs/user-docs/router-lite/configuring-routes.md
+++ b/docs/user-docs/router-lite/configuring-routes.md
@@ -862,6 +862,102 @@ You can see this configuration in action below.
 
 {% embed url="https://stackblitz.com/edit/router-lite-component-ce-instance-jx3kee?ctl=1&embed=1&file=src/my-app.ts" %}
 
+### Using conventional HTML-only custom element
+
+When using HTML-only custom elements, facilitated via the convention, the custom element can be directly used while configuring routes.
+
+```diff
+  import { customElement } from '@aurelia/runtime-html';
+  import { route } from '@aurelia/router-lite';
+  import template from './my-app.html';
+- import { About } from './about';
+- import { Home } from './home';
++ import * as About from './about.html';
++ import * as Home from './home.html';
+
+  @route({
+    routes: [
+      {
+        path: ['', 'home'],
+        component: Home,
+        title: 'Home',
+      },
+      {
+        path: 'about',
+        component: About,
+        title: 'About',
+      },
+    ],
+  })
+@customElement({ name: 'my-app', template })
+export class MyApp {}
+```
+
+Using `import()` function directly is also supported.
+
+```diff
+  import { customElement } from '@aurelia/runtime-html';
+  import { route } from '@aurelia/router-lite';
+  import template from './my-app.html';
+- import { About } from './about';
+- import { Home } from './home';
+
+  @route({
+    routes: [
+      {
+        path: ['', 'home'],
+-       component: Home,
++       component: import('./home.html'),
+        title: 'Home',
+      },
+      {
+        path: 'about',
+-       component: About,
++       component: import('./about.html'),
+        title: 'About',
+      },
+    ],
+  })
+@customElement({ name: 'my-app', template })
+export class MyApp {}
+```
+
+When importing the HTML-only custom elements in the HTML file using `<require from="">` syntax, use the custom element name in the route configuration.
+
+```html
+  <require from="./about.html"></require>
+  <require from="./home.html"></require>
+
+  <au-viewport></au-viewport>
+```
+
+```diff
+  import { customElement } from '@aurelia/runtime-html';
+  import { route } from '@aurelia/router-lite';
+  import template from './my-app.html';
+- import { About } from './about';
+- import { Home } from './home';
+
+  @route({
+    routes: [
+      {
+        path: ['', 'home'],
+-       component: Home,
++       component: 'home',
+        title: 'Home',
+      },
+      {
+        path: 'about',
+-       component: About,
++       component: 'about',
+        title: 'About',
+      },
+    ],
+  })
+@customElement({ name: 'my-app', template })
+export class MyApp {}
+```
+
 ## Using classes as routes
 
 Using router-lite it is also possible to use the routed view model classes directly as routes configuration.
diff --git a/packages/__tests__/src/router-lite/smoke-tests.spec.ts b/packages/__tests__/src/router-lite/smoke-tests.spec.ts
index 60e6c200e3..d6d587c02a 100644
--- a/packages/__tests__/src/router-lite/smoke-tests.spec.ts
+++ b/packages/__tests__/src/router-lite/smoke-tests.spec.ts
@@ -7585,4 +7585,202 @@ describe('router-lite/smoke-tests.spec.ts', function () {
 
     await au.stop(true);
   });
+
+  /**
+   * Reported in https://discourse.aurelia.io/t/router-questions/6360/3
+   * Use case:
+   *
+   * ```ts
+   * import { route } from '@aurelia/router-lite';
+   * import * about from './about.html'; // <-- loaded via convention plugin
+   * @route({
+   *   routes: [
+   *     {
+   *       path: 'about',
+   *       component: about,
+   *     },
+   *   ],
+   * })
+   * export class MyApp {}
+   * ```
+   */
+  it('Routing to conventional HTML-only custom element works', async function () {
+    const ceOne = {
+      name: 'ce-one',
+      template: 'ce-one',
+      dependencies: [],
+      bindables: {},
+      _e: undefined,
+      register: (container) => {
+        if (!ceOne._e) {
+          ceOne._e = CustomElement.define({ name: ceOne.name, template: ceOne.template, dependencies: ceOne.dependencies, bindables: ceOne.bindables });
+        }
+        container.register(ceOne._e);
+      }
+    };
+    const ceTwo = {
+      name: 'ce-two',
+      template: 'ce-two',
+      dependencies: [],
+      bindables: {},
+      _e: undefined,
+      register: (container) => {
+        if (!ceTwo._e) {
+          ceTwo._e = CustomElement.define({ name: ceTwo.name, template: ceTwo.template, dependencies: ceTwo.dependencies, bindables: ceTwo.bindables });
+        }
+        container.register(ceTwo._e);
+      }
+    };
+
+    @route({
+      routes: [
+        { id: 'ce-one', path: 'ce-one', component: ceOne },
+        { id: 'ce-two', path: 'ce-two', component: ceTwo },
+      ]
+    })
+    @customElement({ name: 'root', template: '<au-viewport></au-viewport>' })
+    class Root { }
+
+    const { container, host, au } = await start({ appRoot: Root });
+
+    const router = container.get(IRouter);
+
+    await router.load('ce-one');
+    assert.html.textContent(host, 'ce-one');
+
+    await router.load('ce-two');
+    assert.html.textContent(host, 'ce-two');
+
+    await au.stop(true);
+  });
+
+  /**
+   * Reported in https://discourse.aurelia.io/t/router-questions/6360/3
+   * Use case:
+   *
+   * ```ts
+   * import { route } from '@aurelia/router-lite';
+   * import * about from './about.html'; // <-- loaded via convention plugin
+   * @route({
+   *   routes: [
+   *     {
+   *       path: 'about',
+   *       component: import('./about.html'),
+   *     },
+   *   ],
+   * })
+   * export class MyApp {}
+   * ```
+   */
+  it('Routing to conventional HTML-only custom element works - lazy loading', async function () {
+    const ceOne = {
+      name: 'ce-one',
+      template: 'ce-one',
+      dependencies: [],
+      bindables: {},
+      _e: undefined,
+      register: (container) => {
+        if (!ceOne._e) {
+          ceOne._e = CustomElement.define({ name: ceOne.name, template: ceOne.template, dependencies: ceOne.dependencies, bindables: ceOne.bindables });
+        }
+        container.register(ceOne._e);
+      }
+    };
+    const ceTwo = {
+      name: 'ce-two',
+      template: 'ce-two',
+      dependencies: [],
+      bindables: {},
+      _e: undefined,
+      register: (container) => {
+        if (!ceTwo._e) {
+          ceTwo._e = CustomElement.define({ name: ceTwo.name, template: ceTwo.template, dependencies: ceTwo.dependencies, bindables: ceTwo.bindables });
+        }
+        container.register(ceTwo._e);
+      }
+    };
+
+    @route({
+      routes: [
+        { id: 'ce-one', path: 'ce-one', component: Promise.resolve(ceOne) },
+        { id: 'ce-two', path: 'ce-two', component: Promise.resolve(ceTwo) },
+      ]
+    })
+    @customElement({ name: 'root', template: '<au-viewport></au-viewport>' })
+    class Root { }
+
+    const { container, host, au } = await start({ appRoot: Root });
+
+    const router = container.get(IRouter);
+
+    await router.load('ce-one');
+    assert.html.textContent(host, 'ce-one');
+
+    await router.load('ce-two');
+    assert.html.textContent(host, 'ce-two');
+
+    await au.stop(true);
+  });
+
+  /**
+   * Reported in https://discourse.aurelia.io/t/router-questions/6360/3
+   * Use case:
+   *
+   * ```html
+   * <template>
+   *  <require from="./about.html"></require>
+   *  <require from="./home.html"></require>
+   *  <au-viewport></au-viewport>
+   * </template>
+   * ```
+   */
+  it('Routing to conventional HTML-only custom element works - required via HTML', async function () {
+    const ceOne = {
+      name: 'ce-one',
+      template: 'ce-one',
+      dependencies: [],
+      bindables: {},
+      _e: undefined,
+      register: (container) => {
+        if (!ceOne._e) {
+          ceOne._e = CustomElement.define({ name: ceOne.name, template: ceOne.template, dependencies: ceOne.dependencies, bindables: ceOne.bindables });
+        }
+        container.register(ceOne._e);
+      }
+    };
+    const ceTwo = {
+      name: 'ce-two',
+      template: 'ce-two',
+      dependencies: [],
+      bindables: {},
+      _e: undefined,
+      register: (container) => {
+        if (!ceTwo._e) {
+          ceTwo._e = CustomElement.define({ name: ceTwo.name, template: ceTwo.template, dependencies: ceTwo.dependencies, bindables: ceTwo.bindables });
+        }
+        container.register(ceTwo._e);
+      }
+    };
+
+    @route({
+      routes: [
+        { id: 'ce-one', path: 'ce-one', component: 'ce-one' },
+        { id: 'ce-two', path: 'ce-two', component: 'ce-two' },
+      ]
+    })
+    @customElement({ name: 'root', template: '<au-viewport></au-viewport>', dependencies: [ceOne, ceTwo] })
+    class Root { }
+
+    const { container, host, au } = await start({ appRoot: Root });
+
+    const router = container.get(IRouter);
+
+    await router.load('ce-one');
+    assert.html.textContent(host, 'ce-one');
+
+    await router.load('ce-two');
+    assert.html.textContent(host, 'ce-two');
+
+    await au.stop(true);
+  });
 });
diff --git a/packages/router-lite/src/instructions.ts b/packages/router-lite/src/instructions.ts
index 7859bdcc23..abaac5ae3e 100644
--- a/packages/router-lite/src/instructions.ts
+++ b/packages/router-lite/src/instructions.ts
@@ -18,7 +18,7 @@ import {
 import { IRouteViewModel } from './component-agent';
 import { RouteType } from './route';
 import { type $RecognizedRoute, IRouteContext, RouteContext } from './route-context';
-import { expectType, isPartialViewportInstruction, shallowEquals } from './validation';
+import { expectType, isPartialCustomElementDefinition, isPartialViewportInstruction, shallowEquals } from './validation';
 import { INavigationOptions, NavigationOptions, type RouterOptions } from './options';
 import { RouteExpression } from './route-expression';
 import { mergeURLSearchParams, tryStringify } from './util';
@@ -459,6 +459,24 @@ export class TypedNavigationInstruction<TInstruction extends NavigationInstructi
     if (isCustomElementViewModel(instruction)) return new TypedNavigationInstruction(NavigationInstructionType.IRouteViewModel, instruction);
     // We might have gotten a complete definition. In that case use it as-is.
     if (instruction instanceof CustomElementDefinition) return new TypedNavigationInstruction(NavigationInstructionType.CustomElementDefinition, instruction);
+
+    // If we have a partial definition, create a complete definition from it.
+    // Use-case:
+    // import * as component from './conventional-html-only-component.html';
+    // @route({
+    //   routes: [
+    //     {
+    //       path: 'path',
+    //       component,
+    //     },
+    //   ],
+    // })
+    if (isPartialCustomElementDefinition(instruction)) {
+      const definition = CustomElementDefinition.create(instruction);
+      CustomElement.define(definition);
+      return new TypedNavigationInstruction(NavigationInstructionType.CustomElementDefinition, definition);
+    }
+
     throw new Error(getMessage(Events.instrInvalid, tryStringify(instruction)));
   }
 
diff --git a/packages/router-lite/src/route-context.ts b/packages/router-lite/src/route-context.ts
index dccb2f6a4c..d6f4358146 100644
--- a/packages/router-lite/src/route-context.ts
+++ b/packages/router-lite/src/route-context.ts
@@ -15,7 +15,7 @@ import { type Endpoint, RecognizedRoute, RESIDUE, RouteRecognizer } from '@aurel
 import {
   Controller,
   CustomElement,
-  type CustomElementDefinition,
+  CustomElementDefinition,
   IAppRoot,
   IController,
   ICustomElementController,
@@ -52,7 +52,7 @@ import {
 } from './router';
 import { IRouterEvents } from './router-events';
 import { ensureArrayOfStrings } from './util';
-import { isPartialChildRouteConfig } from './validation';
+import { isPartialChildRouteConfig, isPartialCustomElementDefinition } from './validation';
 import { ViewportAgent, type ViewportRequest } from './viewport-agent';
 import { Events, debug, error, getMessage, logAndThrow, trace } from './events';
 
@@ -517,7 +517,14 @@ export class RouteContext {
         }
       }
 
-      if (defaultExport === void 0 && firstNonDefaultExport === void 0) throw new Error(getMessage(Events.rcInvalidLazyImport, promise));
+      if (defaultExport === void 0 && firstNonDefaultExport === void 0) {
+        if (!isPartialCustomElementDefinition(raw)) throw new Error(getMessage(Events.rcInvalidLazyImport, promise));
+
+        // use-case: import('./conventional-html-only-component.html')
+        const definition = CustomElementDefinition.create(raw);
+        CustomElement.define(definition);
+        return definition;
+      }
 
       return firstNonDefaultExport ?? defaultExport!;
     });
diff --git a/packages/router-lite/src/route.ts b/packages/router-lite/src/route.ts
index a542d3ba6a..333d2babcc 100644
--- a/packages/router-lite/src/route.ts
+++ b/packages/router-lite/src/route.ts
@@ -1,11 +1,11 @@
 import { Metadata } from '@aurelia/metadata';
 import { Constructable, emptyArray, onResolve, ResourceType, Writable, getResourceKeyFor } from '@aurelia/kernel';
 
-import { validateRouteConfig, expectType, shallowEquals, isPartialRedirectRouteConfig, isPartialChildRouteConfig } from './validation';
+import { validateRouteConfig, expectType, shallowEquals, isPartialRedirectRouteConfig, isPartialChildRouteConfig, isPartialCustomElementDefinition } from './validation';
 import { defaultViewportName, ITypedNavigationInstruction_Component, NavigationInstructionType, TypedNavigationInstruction, ViewportInstruction } from './instructions';
 import type { RouteNode } from './route-tree';
 import type { IRouteContext } from './route-context';
-import { CustomElement, CustomElementDefinition } from '@aurelia/runtime-html';
+import { CustomElement, CustomElementDefinition, PartialCustomElementDefinition } from '@aurelia/runtime-html';
 import { IRouteViewModel } from './component-agent';
 import { ensureArrayOfStrings, ensureString } from './util';
 import type { FallbackFunction, IChildRouteConfig, IRedirectRouteConfig, IRouteConfig, Routeable, TransitionPlan, TransitionPlanOrFunc } from './options';
@@ -317,10 +317,16 @@ export function resolveCustomElementDefinition(routeable: Routeable, context: IR
     case NavigationInstructionType.string: {
       if (context == null) throw new Error(getMessage(Events.rtNoCtxStrComponent));
 
-      const component = CustomElement.find(context.container, instruction.value);
+      const dependencies = context.component.dependencies;
+      let component = dependencies.find(d => isPartialCustomElementDefinition(d) && d.name === instruction.value) as CustomElementDefinition | PartialCustomElementDefinition
+        ?? CustomElement.find(context.container, instruction.value);
       if (component === null) throw new Error(getMessage(Events.rtNoComponent, instruction.value, context));
 
-      ceDef = component;
+      if (!(component instanceof CustomElementDefinition)) {
+        component = CustomElementDefinition.create(component);
+        CustomElement.define(component);
+      }
+      ceDef = component as CustomElementDefinition;
       break;
     }
     case NavigationInstructionType.CustomElementDefinition:


Reviews:
user: login: bigopon
body: nice work @Sayan751 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-02T01:08:18Z
user: login: Sayan751
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T09:13:15Z

Review comments:
diff_hunk: @@ -459,6 +459,24 @@ export class TypedNavigationInstruction<TInstruction extends NavigationInstructi
     if (isCustomElementViewModel(instruction)) return new TypedNavigationInstruction(NavigationInstructionType.IRouteViewModel, instruction);
     // We might have gotten a complete definition. In that case use it as-is.
     if (instruction instanceof CustomElementDefinition) return new TypedNavigationInstruction(NavigationInstructionType.CustomElementDefinition, instruction);
+
+    // If we have a partial definition, create a complete definition from it.
+    // Use-case:
+    // import * as component from './conventional-html-only-component.html';
+    // @route({
+    //   routes: [
+    //     {
+    //       path: 'path',
+    //       component,
+    //     },
+    //   ],
+    // })
+    if (isPartialCustomElementDefinition(instruction)) {
+      const definition = CustomElementDefinition.create(instruction);
+      CustomElement.define(definition);
path: packages/router-lite/src/instructions.ts
user: login: bigopon
body: do we need this line?
created_at: 2025-03-02T01:07:44Z
author_association: MEMBER
diff_hunk: @@ -459,6 +459,24 @@ export class TypedNavigationInstruction<TInstruction extends NavigationInstructi
     if (isCustomElementViewModel(instruction)) return new TypedNavigationInstruction(NavigationInstructionType.IRouteViewModel, instruction);
     // We might have gotten a complete definition. In that case use it as-is.
     if (instruction instanceof CustomElementDefinition) return new TypedNavigationInstruction(NavigationInstructionType.CustomElementDefinition, instruction);
+
+    // If we have a partial definition, create a complete definition from it.
+    // Use-case:
+    // import * as component from './conventional-html-only-component.html';
+    // @route({
+    //   routes: [
+    //     {
+    //       path: 'path',
+    //       component,
+    //     },
+    //   ],
+    // })
+    if (isPartialCustomElementDefinition(instruction)) {
+      const definition = CustomElementDefinition.create(instruction);
+      CustomElement.define(definition);
path: packages/router-lite/src/instructions.ts
user: login: Sayan751
body: Yes. Because, `CustomElement.define` defines the custom-element metadata on the type. Later that metadata is needed to determine the path (name of the CE). This can be rewritten as `CustomElement.getDefinition(CustomElement.define(instruction))`; however, not sure if that is any better.
created_at: 2025-03-02T09:13:15Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-03-01T09:42:26Z
body: ## [Codecov](https://app.codecov.io/gh/aurelia/aurelia/pull/2126?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aurelia) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 88.29%. Comparing base [(`7e652e9`)](https://app.codecov.io/gh/aurelia/aurelia/commit/7e652e90432faebfd9d4714704a845bcaabb805c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aurelia) to head [(`0b0cba1`)](https://app.codecov.io/gh/aurelia/aurelia/commit/0b0cba15d6a69ebc083d634d5cf21a83b2925434?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aurelia).
> Report is 4 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #2126      +/-   ##
==========================================
+ Coverage   83.57%   88.29%   +4.72%     
==========================================
  Files         267      281      +14     
  Lines       17981    18390     +409     
  Branches     2881     2930      +49     
==========================================
+ Hits        15028    16238    +1210     
+ Misses       2953     2152     -801     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aurelia/aurelia/pull/2126?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aurelia).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aurelia).

author_association: NONE
user: login: Sayan751
created_at: 2025-03-03T14:01:22Z
body: @bigopon I am merging this PR. In case you notice other issues, let me know.
author_association: MEMBER