Skip to content
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

feat(angular): use standalone components with routing #25589

Merged
merged 12 commits into from
Aug 30, 2022
Merged
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
2 changes: 1 addition & 1 deletion angular/src/di/r3_injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ export abstract class EnvironmentInjector implements Injector {
/**
* @internal
*/
abstract onDestroy(callback: () => void): void;
abstract onDestroy?(callback: () => void): void;
Copy link
Contributor

Choose a reason for hiding this comment

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

Why was this change needed?

Copy link
Contributor Author

@sean-perkins sean-perkins Aug 25, 2022

Choose a reason for hiding this comment

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

This was to align with the type signature of the EnvironmentInjector type from @angular/core. We are maintaining our own "version" of the types, since we cannot update our library dependencies to target Angular 14. When I originally ported this, I forgot to make the onDestroy callback optional.

Without this change, developers would receive a typescript language server warning in their IDE that the type of their environmentInjector does not match the type signature of what we are expecting to be passed in.

}
41 changes: 29 additions & 12 deletions angular/src/directives/navigation/ion-router-outlet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Optional,
Output,
SkipSelf,
Input,
} from '@angular/core';
import { OutletContext, Router, ActivatedRoute, ChildrenOutletContexts, PRIMARY_OUTLET } from '@angular/router';
import { componentOnReady } from '@ionic/core';
Expand Down Expand Up @@ -55,6 +56,16 @@ export class IonRouterOutlet implements OnDestroy, OnInit {

tabsPrefix: string | undefined;

/**
* @experimental
*
* The `EnvironmentInjector` provider instance from the parent component.
* Required for using standalone components with `ion-router-outlet`.
*
* Will be deprecated and removed when Angular 13 support is dropped.
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need a tech debt ticket for this? (Ditto for ion-tabs)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm indifferent since this is an experimental API that could be ripped out completely if either Angular doesn't proceed with standalone as part of the official public API or if don't think it is a good architectural fit with Ionic.

I think once we consider it public API, having tech debt to maintain it could make sense.

But we also don't have many experimental features, so if we want to follow our existing process like everything else, I'm happy to create tickets 👍

*/
@Input() environmentInjector: EnvironmentInjector;

@Output() stackEvents = new EventEmitter<any>();
// eslint-disable-next-line @angular-eslint/no-output-rename
@Output('activate') activateEvents = new EventEmitter<any>();
Expand Down Expand Up @@ -88,7 +99,6 @@ export class IonRouterOutlet implements OnDestroy, OnInit {
@Optional() @Attribute('tabs') tabs: string,
private config: Config,
private navCtrl: NavController,
@Optional() private environmentInjector: EnvironmentInjector,
@Optional() private componentFactoryResolver: ComponentFactoryResolver,
commonLocation: Location,
elementRef: ElementRef,
Expand Down Expand Up @@ -234,20 +244,24 @@ export class IonRouterOutlet implements OnDestroy, OnInit {
} else {
const snapshot = (activatedRoute as any)._futureSnapshot;

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const component = snapshot.routeConfig!.component as any;

/**
* Angular 14 introduces a new `loadComponent` property to the route config,
* that assigns the component to load to the `component` property of
* the route snapshot. We can check for the presence of this property
* to determine if the route is using standalone components.
*
* TODO: FW-1631: Remove this check when supporting standalone components
* Angular 14 introduces a new `loadComponent` property to the route config.
* This function will assign a `component` property to the route snapshot.
* We check for the presence of this property to determine if the route is
* using standalone components.
*/
if (component == null && snapshot.component) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (snapshot.routeConfig!.component == null && this.environmentInjector == null) {
console.warn(
'[Ionic Warning]: Standalone components are not currently supported with ion-router-outlet. You can track this feature request at https://github.com/ionic-team/ionic-framework/issues/25404'
'[Ionic Warning]: You must supply an environmentInjector to use standalone components with routing:\n\n' +
'In your component class, add:\n\n' +
` import { EnvironmentInjector } from '@angular/core';\n` +
' constructor(public environmentInjector: EnvironmentInjector) {}\n' +
'\n' +
'In your router outlet template, add:\n\n' +
' <ion-router-outlet [environmentInjector]="environmentInjector"></ion-router-outlet>\n\n' +
'Alternatively, if you are routing within ion-tabs:\n\n' +
' <ion-tabs [environmentInjector]="environmentInjector"></ion-tabs>'
);
return;
}
Expand All @@ -267,6 +281,9 @@ export class IonRouterOutlet implements OnDestroy, OnInit {
*/
resolverOrInjector = resolverOrInjector || this.componentFactoryResolver;

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const component = snapshot.routeConfig!.component ?? snapshot.component;

if (resolverOrInjector && isComponentFactoryResolver(resolverOrInjector)) {
// Backwards compatibility for Angular 13 and lower
const factory = resolverOrInjector.resolveComponentFactory(component);
Expand Down
20 changes: 18 additions & 2 deletions angular/src/directives/navigation/ion-tabs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Component, ContentChild, EventEmitter, HostListener, Output, ViewChild } from '@angular/core';
import { Component, ContentChild, EventEmitter, HostListener, Input, Output, ViewChild } from '@angular/core';

import { EnvironmentInjector } from '../../di/r3_injector';
import { NavController } from '../../providers/nav-controller';
import { IonTabBar } from '../proxies';

Expand All @@ -10,7 +11,12 @@ import { StackEvent } from './stack-utils';
selector: 'ion-tabs',
template: ` <ng-content select="[slot=top]"></ng-content>
<div class="tabs-inner">
<ion-router-outlet #outlet tabs="true" (stackEvents)="onPageSelected($event)"></ion-router-outlet>
<ion-router-outlet
#outlet
tabs="true"
[environmentInjector]="environmentInjector"
(stackEvents)="onPageSelected($event)"
></ion-router-outlet>
</div>
<ng-content></ng-content>`,
styles: [
Expand Down Expand Up @@ -46,6 +52,16 @@ export class IonTabs {
@ViewChild('outlet', { read: IonRouterOutlet, static: false }) outlet: IonRouterOutlet;
@ContentChild(IonTabBar, { static: false }) tabBar: IonTabBar | undefined;

/**
* @experimental
*
* The `EnvironmentInjector` provider instance from the parent component.
* Required for using standalone components with `ion-router-outlet`.
*
* Will be deprecated and removed when Angular 13 support is dropped.
*/
@Input() environmentInjector: EnvironmentInjector;

@Output() ionTabsWillChange = new EventEmitter<{ tab: string }>();
@Output() ionTabsDidChange = new EventEmitter<{ tab: string }>();

Expand Down
9 changes: 9 additions & 0 deletions angular/test/apps/ng14/e2e/src/standalone-routing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
describe('Routing with Standalone Components', () => {
beforeEach(() => {
cy.visit('/version-test/standalone');
});

it('should render the component', () => {
cy.get('ion-content').contains('This is a standalone component rendered from a route.');
});
});
3 changes: 3 additions & 0 deletions angular/test/apps/ng14/src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<ion-app>
<ion-router-outlet [environmentInjector]="environmentInjector"></ion-router-outlet>
</ion-app>
11 changes: 11 additions & 0 deletions angular/test/apps/ng14/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Component, EnvironmentInjector } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {

constructor(public environmentInjector: EnvironmentInjector) { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<ion-content>
<p>This is a standalone component rendered from a route.</p>
<ion-button routerLink="/">Return home</ion-button>
</ion-content>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Component } from "@angular/core";
import { RouterModule } from "@angular/router";

import { IonicModule } from '@ionic/angular';

@Component({
selector: 'app-standalone',
templateUrl: './standalone.component.html',
standalone: true,
imports: [IonicModule, RouterModule]
})
export class StandaloneComponent { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";

@NgModule({
imports: [
RouterModule.forChild([
{
path: 'standalone',
loadComponent: () => import('./standalone/standalone.component').then(m => m.StandaloneComponent)
}
])
],
exports: [RouterModule]
})
export class VersionTestRoutingModule { }
7 changes: 0 additions & 7 deletions angular/test/base/README.md

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { VersionTestComponent } from ".";
import { VersionTestComponent } from "./version-test.component";

@NgModule({
imports: [
Expand Down
21 changes: 21 additions & 0 deletions angular/test/base/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"compileOnSave": false,
"compilerOptions": {
"importHelpers": true,
"outDir": "./dist/out-tsc",
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"module": "es2020",
"target": "es2020",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"typeRoots": ["node_modules/@types"],
"lib": ["es2018", "dom"],
"plugins": [
{
"name": "typescript-eslint-language-service"
}
]
}
}
2 changes: 2 additions & 0 deletions angular/test/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ cp -R $FULL_BASE_DIR $BUILD_APP_DIR
# Then we can copy the specific app which
# will override any files in the base application.
cp -R $FULL_APP_DIR $BUILD_APP_DIR

echo "Copied test app files for ${APP_DIR}"
35 changes: 35 additions & 0 deletions angular/test/sync-e2e.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/bin/bash

# The directory where the source
# for each specific application is.
APPS_DIR="apps"

# The directory where the
# base application logic is
BASE_DIR="base"
BUILD_DIR="build"

# The specific application
# we are building
APP_DIR="${1}"

# The full path to the specific application.
FULL_APP_DIR="${APPS_DIR}/${APP_DIR}/e2e"

# The full path to the base application.
FULL_BASE_DIR="${BASE_DIR}/e2e"

# The full path to the built application.
BUILD_APP_DIR="${BUILD_DIR}/${APP_DIR}/"

# Make the build directory if it does not already exist.
mkdir -p $BUILD_DIR

# First we need to copy the base application
cp -R $FULL_BASE_DIR $BUILD_APP_DIR

# Then we can copy the specific app which
# will override any files in the base application.
cp -R $FULL_APP_DIR $BUILD_APP_DIR

echo "Synced e2e tests for ${APP_DIR}"