[BUGFIX] Adjust processing order of routes during URL generation 77/64877/6
authorOliver Hader <oliver@typo3.org>
Mon, 15 Jun 2020 16:04:45 +0000 (18:04 +0200)
committerOliver Hader <oliver.hader@typo3.org>
Wed, 15 Jul 2020 21:13:31 +0000 (23:13 +0200)
Prior to this patch routes were processed in reverse definition order.
Routes defined last came first. Depending on the existence of variable
defaults this behavior produced a couple of unexpected results.

  first:
    routePath: '/route/{a}/{b}'
  second:
    routePath: '/route/{c}'
    defaults:
      c: '0'

The example above, processed in reverse order and having parameters
`a` and `b` given, still resulted in `second` route being used since
the missing parameter `c` was defined by corresponding variable default.

This change adjusts the order of routes depending on given parameters,
completeness of a route (when all parameters are given, even defaults).
Sorting is adjusted based on the following criteria:

* default routes (e.g. `/my-page`) - processed later
* static routes (e.g. `/my-page/list`) - processed later
* all variables are given (complete) - processed earlier
  (e.g. parameters `a` and `b` are given for route `/route/{a}/{b}`)
* all mandatory variables are given (complete) - processed earlier
* less missing variable defaults - processed earlier
* less variable defaults amount - processed earlier

Tests in class `RouteSorterTest` provide more examples & details.

Resolves: #90924
Releases: master, 10.4, 9.5
Change-Id: I26f56e6905472a501ff487295da23b3bc3b5c77e
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/64877
Tested-by: TYPO3com <noreply@typo3.com>
Tested-by: Benni Mack <benni@typo3.org>
Tested-by: Aimeos <aimeos@aimeos.org>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
Reviewed-by: Aimeos <aimeos@aimeos.org>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
typo3/sysext/core/Classes/Routing/Enhancer/PluginEnhancer.php
typo3/sysext/core/Classes/Routing/Enhancer/SimpleEnhancer.php
typo3/sysext/core/Classes/Routing/PageRouter.php
typo3/sysext/core/Classes/Routing/RouteSorter.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Routing/RouteSorterTest.php [new file with mode: 0644]
typo3/sysext/extbase/Classes/Routing/ExtbasePluginEnhancer.php

index 7f23d13..e525138 100644 (file)
@@ -122,9 +122,9 @@ class PluginEnhancer extends AbstractEnhancer implements RoutingEnhancerInterfac
         $variant = clone $defaultPageRoute;
         $variant->setPath(rtrim($variant->getPath(), '/') . '/' . ltrim($routePath, '/'));
         $variant->addOptions(['_enhancer' => $this, '_arguments' => $arguments]);
-        $variant->setDefaults(
-            $variableProcessor->deflateKeys($this->configuration['defaults'] ?? [], $this->namespace, $arguments)
-        );
+        $defaults = $variableProcessor->deflateKeys($this->configuration['defaults'] ?? [], $this->namespace, $arguments);
+        // only keep `defaults` that are actually used in `routePath`
+        $variant->setDefaults($this->filterValuesByPathVariables($variant, $defaults));
         $this->applyRouteAspects($variant, $this->aspects ?? [], $this->namespace);
         $this->applyRequirements($variant, $this->configuration['requirements'] ?? [], $this->namespace);
         return $variant;
@@ -143,11 +143,12 @@ class PluginEnhancer extends AbstractEnhancer implements RoutingEnhancerInterfac
         $defaultPageRoute = $collection->get('default');
         $variant = $this->getVariant($defaultPageRoute, $this->configuration);
         $compiledRoute = $variant->compile();
+        // contains all given parameters, even if not used as variables in route
         $deflatedParameters = $this->deflateParameters($variant, $parameters);
         $variables = array_flip($compiledRoute->getPathVariables());
         $mergedParams = array_replace($variant->getDefaults(), $deflatedParameters);
         // all params must be given, otherwise we exclude this variant
-        if ($diff = array_diff_key($variables, $mergedParams)) {
+        if ($variables === [] || array_diff_key($variables, $mergedParams) !== []) {
             return;
         }
         $variant->addOptions(['deflatedParameters' => $deflatedParameters]);
index c8c3489..0432592 100644 (file)
@@ -109,8 +109,10 @@ class SimpleEnhancer extends AbstractEnhancer implements RoutingEnhancerInterfac
         $routePath = $variableProcessor->deflateRoutePath($routePath, null, $arguments);
         $variant = clone $defaultPageRoute;
         $variant->setPath(rtrim($variant->getPath(), '/') . '/' . ltrim($routePath, '/'));
-        $variant->setDefaults($variableProcessor->deflateKeys($this->configuration['defaults'] ?? [], null, $arguments));
         $variant->addOptions(['_enhancer' => $this, '_arguments' => $arguments]);
+        $defaults = $variableProcessor->deflateKeys($this->configuration['defaults'] ?? [], null, $arguments);
+        // only keep `defaults` that are actually used in `routePath`
+        $variant->setDefaults($this->filterValuesByPathVariables($variant, $defaults));
         $this->applyRouteAspects($variant, $this->aspects ?? []);
         $this->applyRequirements($variant, $this->configuration['requirements'] ?? []);
         return $variant;
@@ -125,11 +127,12 @@ class SimpleEnhancer extends AbstractEnhancer implements RoutingEnhancerInterfac
         $defaultPageRoute = $collection->get('default');
         $variant = $this->getVariant($defaultPageRoute, $this->configuration);
         $compiledRoute = $variant->compile();
+        // contains all given parameters, even if not used as variables in route
         $deflatedParameters = $this->getVariableProcessor()->deflateParameters($parameters, $variant->getArguments());
         $variables = array_flip($compiledRoute->getPathVariables());
         $mergedParams = array_replace($variant->getDefaults(), $deflatedParameters);
         // all params must be given, otherwise we exclude this variant
-        if ($diff = array_diff_key($variables, $mergedParams)) {
+        if ($variables === [] || array_diff_key($variables, $mergedParams) !== []) {
             return;
         }
         $variant->addOptions(['deflatedParameters' => $deflatedParameters]);
index 94f331e..d151538 100644 (file)
@@ -194,6 +194,8 @@ class PageRouter implements RouterInterface
             }
             $pageCollection->addNamePrefix($collectionPrefix . '_');
             $fullCollection->addCollection($pageCollection);
+            // set default route flag after all routes have been processed
+            $defaultRouteForPage->setOption('_isDefault', true);
         }
 
         $matcher = new PageUriMatcher($fullCollection);
@@ -307,8 +309,13 @@ class PageRouter implements RouterInterface
         );
         $generator = new UrlGenerator($collection, $context);
         $generator->injectMappableProcessor($mappableProcessor);
-        $allRoutes = $collection->all();
-        $allRoutes = array_reverse($allRoutes, true);
+        // set default route flag after all routes have been processed
+        $defaultRouteForPage->setOption('_isDefault', true);
+        $allRoutes = GeneralUtility::makeInstance(RouteSorter::class)
+            ->withRoutes($collection->all())
+            ->withOriginalParameters($originalParameters)
+            ->sortRoutesForGeneration()
+            ->getRoutes();
         $matchedRoute = null;
         $pageRouteResult = null;
         $uri = null;
diff --git a/typo3/sysext/core/Classes/Routing/RouteSorter.php b/typo3/sysext/core/Classes/Routing/RouteSorter.php
new file mode 100644 (file)
index 0000000..cf4ae62
--- /dev/null
@@ -0,0 +1,233 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Routing;
+
+/**
+ * Pre-processing of given routes based on their actual disposal concerning given parameters.
+ * @internal as this is tightly coupled to Symfony's Routing and we try to encapsulate this, please note that this might change
+ */
+class RouteSorter
+{
+    protected const EARLIER = -1;
+    protected const LATER = 1;
+
+    /**
+     * @var Route[]
+     */
+    protected $routes = [];
+
+    /**
+     * @var array<string, string>
+     */
+    protected $originalParameters = [];
+
+    /**
+     * @return Route[]
+     */
+    public function getRoutes(): array
+    {
+        return $this->routes;
+    }
+
+    public function withRoutes(array $routes): self
+    {
+        $target = clone $this;
+        $target->routes = $routes;
+        return $target;
+    }
+
+    public function withOriginalParameters(array $originalParameters): self
+    {
+        $target = clone $this;
+        $target->originalParameters = $originalParameters;
+        return $target;
+    }
+
+    public function sortRoutesForGeneration(): self
+    {
+        \uasort($this->routes, [$this, 'compareForGeneration']);
+        return $this;
+    }
+
+    protected function compareForGeneration(Route $self, Route $other): int
+    {
+        // default routes (e.g `/my-page`) -> process later
+        return $this->compareDefaultRoutes($self, $other, self::LATER)
+            // no variables (e.g. `/my-page/list`) -> process later
+            ?? $this->compareStaticRoutes($self, $other, self::LATER)
+            // all variables complete -> process earlier
+            ?? $this->compareAllVariablesPresence($self, $other, self::EARLIER)
+            // mandatory variables complete -> process earlier
+            ?? $this->compareMandatoryVariablesPresence($self, $other, self::EARLIER)
+            // more missing variable defaults -> process later
+            ?? $this->compareMissingDefaultsAmount($self, $other, self::LATER)
+            // more variable defaults -> process later
+            ?? $this->compareDefaultsAmount($self, $other, self::LATER)
+            // hm, dunno -> keep position
+            ?? 0;
+    }
+
+    protected function compareDefaultRoutes(Route $self, Route $other, int $action = self::LATER): ?int
+    {
+        $selfIsDefaultRoute = (bool)$self->getOption('_isDefault');
+        $otherIsDefaultRoute = (bool)$other->getOption('_isDefault');
+        // both are default routes, keep order
+        if ($selfIsDefaultRoute && $otherIsDefaultRoute) {
+            return 0;
+        }
+        // $self is default route, sort $self after $other
+        if ($selfIsDefaultRoute && !$otherIsDefaultRoute) {
+            return $action;
+        }
+        // $other is default route, sort $self before $other
+        if (!$selfIsDefaultRoute && $otherIsDefaultRoute) {
+            return -$action;
+        }
+        return null;
+    }
+
+    protected function compareStaticRoutes(Route $self, Route $other, int $action = self::LATER): ?int
+    {
+        $selfVariableNames = $self->compile()->getPathVariables();
+        $otherVariableNames = $other->compile()->getPathVariables();
+        if ($selfVariableNames === [] && $otherVariableNames === []) {
+            return 0;
+        }
+        if ($selfVariableNames === [] && $otherVariableNames !== []) {
+            return $action;
+        }
+        if ($selfVariableNames !== [] && $otherVariableNames === []) {
+            return -$action;
+        }
+        return null;
+    }
+
+    protected function compareAllVariablesPresence(Route $self, Route $other, int $action = self::EARLIER): ?int
+    {
+        $selfVariables = $this->getAllRouteVariables($self);
+        $otherVariables = $this->getAllRouteVariables($other);
+        $missingSelfVariables = \array_diff_key(
+            $selfVariables,
+            $this->getRouteParameters($self)
+        );
+        $missingOtherVariables = \array_diff_key(
+            $otherVariables,
+            $this->getRouteParameters($other)
+        );
+        if ($missingSelfVariables === [] && $missingOtherVariables === []) {
+            $difference = \count($selfVariables) - \count($otherVariables);
+            return $difference * $action;
+        }
+        if ($missingSelfVariables === [] && $missingOtherVariables !== []) {
+            return $action;
+        }
+        if ($missingSelfVariables !== [] && $missingOtherVariables === []) {
+            return -$action;
+        }
+        return null;
+    }
+
+    protected function compareMandatoryVariablesPresence(Route $self, Route $other, int $action = self::EARLIER): ?int
+    {
+        $missingSelfVariables = \array_diff_key(
+            $this->getMandatoryRouteVariables($self),
+            $this->getRouteParameters($self)
+        );
+        $missingOtherVariables = \array_diff_key(
+            $this->getMandatoryRouteVariables($other),
+            $this->getRouteParameters($other)
+        );
+        if ($missingSelfVariables === [] && $missingOtherVariables !== []) {
+            return $action;
+        }
+        if ($missingSelfVariables !== [] && $missingOtherVariables === []) {
+            return -$action;
+        }
+        return null;
+    }
+
+    protected function compareMissingDefaultsAmount(Route $self, Route $other, int $action = self::LATER): ?int
+    {
+        $missingSelfDefaults = \array_diff_key(
+            $this->getActualRouteDefaults($self),
+            $this->getRouteParameters($self)
+        );
+        $missingOtherDefaults = \array_diff_key(
+            $this->getActualRouteDefaults($other),
+            $this->getRouteParameters($other)
+        );
+        $difference = \count($missingSelfDefaults) - \count($missingOtherDefaults);
+        // return `null` in case of equality (`0`)
+        return $difference === 0 ? null : $difference * $action;
+    }
+
+    protected function compareDefaultsAmount(Route $self, Route $other, int $action = self::LATER): ?int
+    {
+        $selfDefaults = $this->getActualRouteDefaults($self);
+        $otherDefaults = $this->getActualRouteDefaults($other);
+        $difference = \count($selfDefaults) - \count($otherDefaults);
+        // return `null` in case of equality (`0`)
+        return $difference === 0 ? null : $difference * $action;
+    }
+
+    /**
+     * Filters route variable defaults that are actually used in route path.
+     *
+     * @param Route $route
+     * @return array<string, string>
+     */
+    protected function getActualRouteDefaults(Route $route): array
+    {
+        return array_intersect_key(
+            $route->getDefaults(),
+            array_flip($route->compile()->getPathVariables())
+        );
+    }
+
+    /**
+     * @param Route $route
+     * @return array<string, int>
+     */
+    protected function getAllRouteVariables(Route $route): array
+    {
+        return array_flip($route->compile()->getPathVariables());
+    }
+
+    /**
+     * @param Route $route
+     * @return array<string, int>
+     */
+    protected function getMandatoryRouteVariables(Route $route): array
+    {
+        return \array_diff_key(
+            $this->getAllRouteVariables($route),
+            $route->getDefaults()
+        );
+    }
+
+    /**
+     * @param Route $route
+     * @return array<string, string>
+     */
+    protected function getRouteParameters(Route $route): array
+    {
+        // $originalParameters is used used as fallback
+        // (custom enhancers should have processed and deflated parameters)
+        return $route->getOption('deflatedParameters') ?? $this->originalParameters;
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/Routing/RouteSorterTest.php b/typo3/sysext/core/Tests/Unit/Routing/RouteSorterTest.php
new file mode 100644 (file)
index 0000000..e46cd27
--- /dev/null
@@ -0,0 +1,273 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Tests\Unit\Routing;
+
+use TYPO3\CMS\Core\Routing\Route;
+use TYPO3\CMS\Core\Routing\RouteSorter;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class RouteSorterTest extends UnitTestCase
+{
+    public function routesAreSortedForGenerationDataProvider(): array
+    {
+        return [
+            'default route only' => [
+                // routes
+                [
+                    $this->createDefaultRoute('/default'),
+                ],
+                // given parameters
+                [],
+                // expected route paths order
+                [
+                    '/default',
+                ]
+            ],
+            'static, default route' => [
+                [
+                    $this->createDefaultRoute('/default-1'),
+                    $this->createRoute('/list'),
+                    $this->createDefaultRoute('/default-2'),
+                ],
+                [],
+                [
+                    '/list',
+                    '/default-1',
+                    '/default-2',
+                ]
+            ],
+            'mandatory, static, default route' => [
+                [
+                    $this->createDefaultRoute('/default'),
+                    $this->createRoute('/list'),
+                    $this->createRoute('/list/{page}', ['page' => 0]),
+                ],
+                [],
+                [
+                    '/list/{page}',
+                    '/list',
+                    '/default',
+                ]
+            ],
+            // not really important, since missing mandatory
+            // variables would have been skipped during generation
+            'ambiguous routes, no parameters, most probable' => [
+                [
+                    $this->createRoute('/list'),
+                    $this->createRoute('/list/{uid}'),
+                    $this->createRoute('/list/{uid}/{category}', ['category' => 0]),
+                    $this->createRoute('/list/{page}', ['page' => 0]),
+                    $this->createRoute('/list/{category}', ['category' => 0]),
+                ],
+                [],
+                [
+                    '/list/{page}', // no parameters given -> defaults take precedence
+                    '/list/{category}', // no parameters given -> defaults take precedence
+                    '/list/{uid}',
+                    '/list/{uid}/{category}',
+                    '/list',
+                ]
+            ],
+            'mandatory first, ambiguous parameters' => [
+                [
+                    $this->createRoute('/list'),
+                    $this->createRoute('/list/{uid}'),
+                    $this->createRoute('/list/{uid}/{category}', ['category' => 0]),
+                    $this->createRoute('/list/{page}', ['page' => 0]),
+                    $this->createRoute('/list/{category}', ['category' => 0]),
+                ],
+                [
+                    'uid' => 123,
+                    'page' => 1,
+                ],
+                [
+                    '/list/{uid}', // value for {uid} given, complete mandatory first -> takes precedence
+                    '/list/{page}', // value for {page} given, complete first -> takes precedence
+                    '/list/{uid}/{category}',
+                    '/list/{category}',
+                    '/list',
+                ]
+            ],
+            'complete first, ambiguous parameters #1' => [
+                [
+                    $this->createRoute('/list'),
+                    $this->createRoute('/list/{uid}'),
+                    $this->createRoute('/list/{uid}/{category}', ['category' => 0]),
+                    $this->createRoute('/list/{page}', ['page' => 0]),
+                    $this->createRoute('/list/{category}', ['category' => 0]),
+                ],
+                [
+                    'category' => 1,
+                    'page' => 1,
+                ],
+                [
+                    '/list/{page}', // value for default {page} given, complete first -> takes precedence
+                    '/list/{category}',
+                    '/list/{uid}',
+                    '/list/{uid}/{category}',
+                    '/list',
+                ]
+            ],
+            'complete first, ambiguous parameters #2' => [
+                [
+                    $this->createRoute('/list'),
+                    $this->createRoute('/list/{uid}'),
+                    $this->createRoute('/list/{uid}/{category}', ['category' => 0]),
+                    $this->createRoute('/list/{page}', ['page' => 0]),
+                    $this->createRoute('/list/{category}', ['category' => 0]),
+                ],
+                [
+                    'uid' => 123,
+                    'page' => 1,
+                    'category' => 2,
+                ],
+                [
+                    '/list/{uid}/{category}', // values for {uid} and {category} given, complete first -> takes precedence
+                    '/list/{uid}',
+                    '/list/{page}',
+                    '/list/{category}',
+                    '/list',
+                ]
+            ],
+            // not really important, just to show order is kept
+            'defaults only, no parameters given #1' => [
+                [
+                    $this->createRoute('/list/{defA}/{defB}/{defC}', ['defA' => 0, 'defB' => 0, 'defC' => 0]),
+                    $this->createRoute('/list/{defD}/{defE}/{defF}', ['defD' => 0, 'defE' => 0, 'defF' => 0]),
+                ],
+                [
+                ],
+                [
+                    '/list/{defA}/{defB}/{defC}',
+                    '/list/{defD}/{defE}/{defF}',
+                ]
+            ],
+            // not really important, just to show order is kept
+            'defaults only, no parameters given #2' => [
+                [
+                    $this->createRoute('/list/{defD}/{defE}/{defF}', ['defD' => 0, 'defE' => 0, 'defF' => 0]),
+                    $this->createRoute('/list/{defA}/{defB}/{defC}', ['defA' => 0, 'defB' => 0, 'defC' => 0]),
+                ],
+                [
+                ],
+                [
+                    '/list/{defD}/{defE}/{defF}',
+                    '/list/{defA}/{defB}/{defC}',
+                ]
+            ],
+            'defaults only, {defF} given, best match' => [
+                [
+                    $this->createRoute('/list/{defA}/{defB}/{defC}', ['defA' => 0, 'defB' => 0, 'defC' => 0]),
+                    $this->createRoute('/list/{defD}/{defE}/{defF}', ['defD' => 0, 'defE' => 0, 'defF' => 0]),
+                ],
+                [
+                    'defF' => 1,
+                ],
+                [
+                    '/list/{defD}/{defE}/{defF}', // {defF} given, best match -> takes precedence
+                    '/list/{defA}/{defB}/{defC}',
+                ]
+            ],
+            'mixed variables, ambiguous parameters, complete mandatory first #1' => [
+                [
+                    $this->createRoute('/list/{d}/{e}/{defF}', ['defF' => 0]),
+                    $this->createRoute('/list/{a}/{defB}/{defC}', ['defB' => 0, 'defC' => 0]),
+                ],
+                [
+                    'a' => 1,
+                    'd' => 1,
+                    'defF' => 1,
+                ],
+                [
+                    '/list/{a}/{defB}/{defC}', // mandatory {a} given, complete mandatory first -> takes precedence
+                    '/list/{d}/{e}/{defF}',
+                ]
+            ],
+            'mixed variables, ambiguous parameters, complete mandatory first #2' => [
+                [
+                    $this->createRoute('/list/{a}/{defB}/{defC}', ['defB' => 0, 'defC' => 0]),
+                    $this->createRoute('/list/{d}/{e}/{defF}', ['defF' => 0]),
+                ],
+                [
+                    'd' => 1,
+                    'e' => 1,
+                    'defB' => 1,
+                    'defC' => 1,
+                ],
+                [
+                    '/list/{d}/{e}/{defF}', // mandatory {d} and {e} given, complete mandatory first -> takes precedence
+                    '/list/{a}/{defB}/{defC}',
+                ]
+            ],
+            'mixed variables, ambiguous parameters, complete first' => [
+                [
+                    $this->createRoute('/list/{d}/{e}/{defF}', ['defF' => 0]),
+                    $this->createRoute('/list/{a}/{defB}/{defC}', ['defB' => 0, 'defC' => 0]),
+                ],
+                [
+                    'd' => 1,
+                    'e' => 1,
+                    'a' => 1,
+                    'defB' => 1,
+                    'defC' => 1,
+                ],
+                [
+                    '/list/{a}/{defB}/{defC}', // all parameters given, complete first -> takes precedence
+                    '/list/{d}/{e}/{defF}',
+                ]
+            ],
+        ];
+    }
+
+    /**
+     * @param Route[] $givenRoutes
+     * @param array<string, string> $givenParameters
+     * @param string[] $expectation
+     *
+     * @test
+     * @dataProvider routesAreSortedForGenerationDataProvider
+     */
+    public function routesAreSortedForGeneration(array $givenRoutes, array $givenParameters, array $expectation): void
+    {
+        $sorter = (new RouteSorter())
+            ->withRoutes($givenRoutes)
+            ->withOriginalParameters($givenParameters);
+        $routes = $sorter->sortRoutesForGeneration()->getRoutes();
+        $routePaths = array_map([$this, 'getRoutePath'], array_values($routes));
+        self::assertSame($expectation, $routePaths);
+    }
+
+    private function getRoutePath(Route $route): string
+    {
+        return $route->getPath();
+    }
+
+    private function createRoute(string $path, array $defaults = []): Route
+    {
+        $route = new Route($path);
+        $route->setDefaults($defaults);
+        return $route;
+    }
+
+    private function createDefaultRoute(string $path): Route
+    {
+        $route = new Route($path);
+        $route->setOption('_isDefault', true);
+        return $route;
+    }
+}
index 8aceb23..d68d258 100644 (file)
@@ -87,14 +87,24 @@ class ExtbasePluginEnhancer extends PluginEnhancer
         $routePath = $this->modifyRoutePath($configuration['routePath']);
         $routePath = $variableProcessor->deflateRoutePath($routePath, $this->namespace, $arguments);
         unset($configuration['routePath']);
+        $options = array_merge($defaultPageRoute->getOptions(), ['_enhancer' => $this, 'utf8' => true, '_arguments' => $arguments]);
+        $route = new Route(rtrim($defaultPageRoute->getPath(), '/') . '/' . ltrim($routePath, '/'), [], [], $options);
+
         $defaults = array_merge_recursive(
             $defaultPageRoute->getDefaults(),
-            $variableProcessor->deflateKeys($this->configuration['defaults'] ?? [], $this->namespace, $arguments),
-            // apply '_controller' to route defaults
+            $variableProcessor->deflateKeys($this->configuration['defaults'] ?? [], $this->namespace, $arguments)
+        );
+        // only keep `defaults` that are actually used in `routePath`
+        $defaults = $this->filterValuesByPathVariables(
+            $route,
+            $defaults
+        );
+        // apply '_controller' to route defaults
+        $defaults = array_merge_recursive(
+            $defaults,
             array_intersect_key($configuration, ['_controller' => true])
         );
-        $options = array_merge($defaultPageRoute->getOptions(), ['_enhancer' => $this, 'utf8' => true, '_arguments' => $arguments]);
-        $route = new Route(rtrim($defaultPageRoute->getPath(), '/') . '/' . ltrim($routePath, '/'), $defaults, [], $options);
+        $route->setDefaults($defaults);
         $this->applyRouteAspects($route, $this->aspects ?? [], $this->namespace);
         $this->applyRequirements($route, $this->configuration['requirements'] ?? [], $this->namespace);
         return $route;
@@ -132,11 +142,12 @@ class ExtbasePluginEnhancer extends PluginEnhancer
             unset($parameters[$this->namespace]['action']);
             unset($parameters[$this->namespace]['controller']);
             $compiledRoute = $variant->compile();
+            // contains all given parameters, even if not used as variables in route
             $deflatedParameters = $this->deflateParameters($variant, $parameters);
             $variables = array_flip($compiledRoute->getPathVariables());
             $mergedParams = array_replace($variant->getDefaults(), $deflatedParameters);
             // all params must be given, otherwise we exclude this variant
-            if ($diff = array_diff_key($variables, $mergedParams)) {
+            if ($variables === [] || array_diff_key($variables, $mergedParams) !== []) {
                 continue;
             }
             $variant->addOptions(['deflatedParameters' => $deflatedParameters]);