Skip to content

Commit 5084b58

Browse files
committed
fix(NavigationManager): properly register closures
Lately there were quite some issues with the NavigationManager, so this tries to clarifies the implementation a bit. But more important this fixes an issue when closures are registered after the setup was already done (see added unit test). Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 11ef574 commit 5084b58

2 files changed

Lines changed: 67 additions & 17 deletions

File tree

lib/private/NavigationManager.php

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class NavigationManager implements INavigationManager {
3434
* negative to keep this group in front of everything else.
3535
* Apps that are not listed keep their own order.
3636
*/
37-
private const DEFAULT_APP_ORDER = [
37+
private const array DEFAULT_APP_ORDER = [
3838
// Basics
3939
'dashboard' => -100,
4040
'files' => -99,
@@ -54,15 +54,20 @@ class NavigationManager implements INavigationManager {
5454
'activity' => -88,
5555
];
5656

57+
protected ?string $activeEntry = null;
5758
/** @var array<string, NavigationEntryOutput> */
5859
protected array $entries = [];
5960
/** @var list<callable(): NavigationEntry> */
6061
protected array $closureEntries = [];
61-
protected ?string $activeEntry = null;
62-
protected array $unreadCounters = [];
63-
protected bool $init = false;
6462
/** User defined app order (cached for the `add` function) */
65-
private ?array $customAppOrder = null;
63+
protected ?array $customAppOrder = null;
64+
/** @var array<string, int> */
65+
protected array $unreadCounters = [];
66+
67+
/** true if the internal state has been initialized */
68+
protected bool $initAppOrderDone = false;
69+
/** true if all apps have been loaded by the App Manager */
70+
protected bool $initSetupDone = false;
6671
/** List of loaded app info */
6772
private array $loadedAppInfo = [];
6873

@@ -80,11 +85,12 @@ public function __construct(
8085

8186
#[Override]
8287
public function add(array|callable $entry): void {
83-
if ($entry instanceof \Closure) {
88+
if (is_callable($entry)) {
8489
$this->closureEntries[] = $entry;
8590
return;
8691
}
87-
$this->init();
92+
// if needed initialize the internal state to allow setting app order and default app
93+
$this->initCustomAppOrder();
8894

8995
$id = $entry['id'];
9096

@@ -201,7 +207,7 @@ public function clear(bool $resetInit = true): void {
201207

202208
if ($resetInit) {
203209
$this->loadedAppInfo = [];
204-
$this->init = false;
210+
$this->initAppOrderDone = false;
205211
}
206212
}
207213

@@ -219,11 +225,11 @@ public function getActiveEntry(): ?string {
219225
* Initialize the internal state.
220226
* This loads the default app mapping and user mapping for app ordering.
221227
*/
222-
private function init(): void {
223-
if ($this->init) {
228+
private function initCustomAppOrder(): void {
229+
if ($this->initAppOrderDone) {
224230
return;
225231
}
226-
$this->init = true;
232+
$this->initAppOrderDone = true;
227233

228234
if ($this->customAppOrder === null) {
229235
if ($this->userSession->isLoggedIn()) {
@@ -237,20 +243,22 @@ private function init(): void {
237243

238244
/**
239245
* Setup the navigation manager.
246+
*
240247
* @internal - This is only used by Nextcloud core to setup the navigation manager. It is not intended for use by apps.
241248
*/
242249
public function setup(): void {
243-
// Resolve app navigation closures
244-
while ($c = array_pop($this->closureEntries)) {
245-
$this->add($c());
246-
}
247-
248250
// Resolve dynamically added navigation entries via event listeners
249251
$this->eventDispatcher->dispatchTyped(new LoadAdditionalEntriesEvent());
252+
253+
// mark setup as done to allow performance optimizations
254+
$this->initSetupDone = true;
250255
}
251256

252257
/**
253-
* Resolve classic info.xml based navigation entires.
258+
* Resolve app navigation entries.
259+
*
260+
* This is called every time by any getter as some code for legacy reasons relies
261+
* on the navigation entries being available before the app loading is finished.
254262
* Some code relies on this to be available earlier then the app loading finished.
255263
* So we need to resolve the navigation entries here, even if not all apps are loaded yet.
256264
*/
@@ -342,6 +350,19 @@ private function resolveAppNavigationEntries(): void {
342350
));
343351
}
344352
}
353+
354+
// once all apps are loaded we can resolve the app navigation closures
355+
if ($this->initSetupDone) {
356+
// This has to be done on every call,
357+
// as apps might add new navigation entries via closures at any time
358+
while ($c = array_pop($this->closureEntries)) {
359+
try {
360+
$this->add($c());
361+
} catch (\Throwable $e) {
362+
$this->logger->error('Failed to add navigation entry from closure', ['exception' => $e]);
363+
}
364+
}
365+
}
345366
}
346367

347368
private function isAdmin(): bool {

tests/lib/NavigationManagerTest.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,35 @@ public function testAddClosure(array $entry, array $expectedEntry): void {
172172
$this->assertEmpty($this->navigationManager->getAll('all'), 'Expected no navigation entry exists after clear()');
173173
}
174174

175+
public function testAddClosureAfterSetup(): void {
176+
$this->navigationManager->setup();
177+
$this->assertEmpty($this->navigationManager->getAll('all'), 'Expected no navigation entry exists');
178+
179+
$numberOfCalls = 0;
180+
$this->navigationManager->add(function () use (&$numberOfCalls) {
181+
$numberOfCalls++;
182+
183+
return [
184+
'id' => 'late entry',
185+
'name' => 'link text',
186+
'order' => 1,
187+
'href' => 'url',
188+
];
189+
});
190+
191+
$this->assertEquals(0, $numberOfCalls, 'Expected that the closure is not called by add()');
192+
193+
$navigationEntries = $this->navigationManager->getAll('all');
194+
$this->assertEquals(1, $numberOfCalls, 'Expected that the closure added after setup() is called by getAll()');
195+
$this->assertCount(1, $navigationEntries, 'Expected that 1 navigation entry exists');
196+
$this->assertArrayHasKey('late entry', $navigationEntries);
197+
198+
$navigationEntries = $this->navigationManager->getAll('all');
199+
$this->assertEquals(1, $numberOfCalls, 'Expected that the closure is only called once');
200+
$this->assertCount(1, $navigationEntries, 'Expected that 1 navigation entry exists');
201+
$this->assertArrayHasKey('late entry', $navigationEntries);
202+
}
203+
175204
public function testAddArrayClearGetAll(): void {
176205
$entry = [
177206
'id' => 'entry id',

0 commit comments

Comments
 (0)