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

fix(@angular/ssr): handle nested redirects not explicitly defined in router config #28906

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

alan-agius4
Copy link
Collaborator

This commit ensures proper handling of nested redirects that are implicitly structured but not explicitly defined in the router configuration.

Closes #28903

@alan-agius4 alan-agius4 added action: review The PR is still awaiting reviews from at least one requested reviewer target: patch This PR is targeted for the next patch release labels Nov 20, 2024
@alan-agius4 alan-agius4 force-pushed the redirects-nested branch 4 times, most recently from 698059e to d8e81e6 Compare November 20, 2024 17:12
dgp1130
dgp1130 previously approved these changes Nov 21, 2024
Copy link
Collaborator

@dgp1130 dgp1130 left a comment

Choose a reason for hiding this comment

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

Question: Is the intent here to make it so redirectTo: '/foo' is relative to the base URL while redirectTo: 'foo' or redirectTo: './foo' is relative to the current route?

I do wonder if redirectTo: 'foo' might be confusing and ambiguous for some devs, but it seems like this aligns with the app router at least?

packages/angular/ssr/src/app.ts Outdated Show resolved Hide resolved
packages/angular/ssr/src/app.ts Outdated Show resolved Hide resolved
packages/angular/ssr/src/routes/ng-routes.ts Outdated Show resolved Hide resolved
@alan-agius4
Copy link
Collaborator Author

alan-agius4 commented Nov 21, 2024

Question: Is the intent here to make it so redirectTo: '/foo' is relative to the base URL while redirectTo: 'foo' or redirectTo: './foo' is relative to the current route?
I do wonder if redirectTo: 'foo' might be confusing and ambiguous for some devs, but it seems like this aligns with the app router at least?

The Angular router offers various ways to define redirects, and the goal here is to support these options. I agree that it can be confusing and ambiguous—personally, I also found it difficult to fully grasp how redirects work. The fact that an empty string is interpreted as a trailing slash.

Example of Route Configuration:

const routes: Routes = [
  {
    path: '',
    pathMatch: 'full',
    redirectTo: 'some',  // Redirects to '/some'
  },
  {
    path: ':param',
    children: [
      {
        path: '', // This is treated as a /
        pathMatch: 'full',
        redirectTo: 'thing',  // Redirects to './thing' (relative to :param/) IE: :param/thing
      },
      {
        path: 'else',  // This is treated as /else
        pathMatch: 'full',
        redirectTo: 'thing',  // Redirects to './thing' (relative to :param/else) IE: :param/else/thing
      },
      {
        path: 'thing',
        component: DummyComponent,
      },
    ],
  },
];

I updated the logic to actually resolve these during extraction.

…router config

This commit ensures proper handling of nested redirects that are implicitly structured but not explicitly defined in the router configuration.

Closes angular#28903
Copy link
Collaborator

@dgp1130 dgp1130 left a comment

Choose a reason for hiding this comment

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

Oof, that router logic seems quite confusing. I recommend writing down some of these rough edges in a doc somewhere and we can potentially have a conversation with Andrew Scott when he gets back about finding opportunities for simplifying some of this stuff.

So far I've generally insisted to align with app router to maintain consistency, reduce scope by not changing the app router, and remove barriers to adoption. But longer term, it would be great to smooth over some of these rough edges in both routers now that we have more ownership over the SSR side of the problem.

*/
export function addTrailingSlash(url: string): string {
// Check if the URL already end with a slash
return url[url.length - 1] === '/' ? url : `${url}/`;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Bug: I think this fails if url === ''?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

return new URL(urlTemplate, baseUrl);
}

if (urlTemplate[0] !== '/') {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Consider: Should this check go first? Otherwise constructUrlWithPlaceholders('foo', new URL('http://example.com/bar')) would be valid?

return new URL(urlTemplate, baseUrl);
}

if (urlTemplate[0] !== '/') {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Consider: urlTemplate.startsWith('/')

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

[0] is considerably faster when checking for a single character

https://jsbench.me/welq15149d/1

* console.log(staticUrl.toString()); // Outputs: 'http://example.com/static/path'
* ```
*/
export function constructUrlWithPlaceholders(urlTemplate: string, baseUrl: URL): URL {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Consider: I got confused way longer than I should have thinking that baseUrl was a representation of baseHref. Maybe the naming could be more clear about the intent here.

Is this function only used for redirects? Maybe we should embed that into the naming like:

function buildRedirectUrl(toPath: string, fromUrl: URL): URL;

WDYT?

* @example
* ```typescript
* const resolvedUrl = constructUrlWithPlaceholders('/:id/details', new URL('http://example.com/user/123'));
* console.log(resolvedUrl.toString()); // Outputs: 'http://example.com/123/details'
Copy link
Collaborator

Choose a reason for hiding this comment

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

Question: I'm not following this example, what kind of router config is this coming from? How do we map :id to 123? The implementation makes it seem like we're zipping the two segment arrays and lining up segments at the same location, but that would match :id with user, not 123. Is there an error in this example or am I just misunderstanding something?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The example is wrong, I’ll update it

* console.log(staticUrl.toString()); // Outputs: 'http://example.com/static/path'
* ```
*/
export function constructUrlWithPlaceholders(urlTemplate: string, baseUrl: URL): URL {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Consider: I suspect this function is coupling two different problems together which might be unrelated:

  1. Resolve a path from a particular URL.
  2. Carry over URL parameters.

I wonder if there's an alternative design which might be able to decouple them. What if we:

  1. Calculated redirects statically by looking at the route list and generating absolute templates.
    const serverRoutes = [
      {
        path: ':id',
        children: [
          { path: 'foo' },
          { path: 'bar', redirectTo: 'foo' }, // Assuming this redirects to `/:id/foo`, which I might be wrong about.
        ],
      },
    ];
    We could take this and generate a static list of absolute routes (['/:id/foo', '/:id/bar']), I imagine we already have something close to this if not this exact thing in the RouteTree.
  2. Still statically, identify redirects between paths ({['/:id/bar']: '/:id/foo'}).
    • Whether this is done at build time or server initialization is up to you, point is we haven't see any real URLs yet.
  3. Then at runtime, we would receive /1234/bar, match that as /:id/bar and extract { id: '1234' }.
  4. Resolve the redirect to /:id/foo.
  5. Apply the same params to get /1234/foo.

I think we already do 1. - 3. this way, but just spelling them out for clarity. My main point is separating 4. and 5. into distinct steps and allow us to decouple path resolution from applying parameters. WDYT?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

4 and 5 are already done separately in constructUrlWithPlaceholders

@@ -546,6 +563,14 @@ export async function extractRoutesAndCreateRouteTree(
metadata.redirectTo = joinUrlParts(baseHref, metadata.redirectTo);
}

// Remove undefined fields
Copy link
Collaborator

Choose a reason for hiding this comment

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

Question: What exactly is the goal with deleting these fields? Do we not want to leak them to users? A more descriptive comment might be helpful here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

To avoid having to update a bunch of unit tests whenever we add a new field or make some changes.

I encountered this also in the preload PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
action: review The PR is still awaiting reviews from at least one requested reviewer area: @angular/ssr target: patch This PR is targeted for the next patch release
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Angular 19 SSR nested redirectTo result in a redirect loop
2 participants