Skip to content

perf(workspace): only touch the widget registry when the bridge is on - #120

Merged
rubenvdlinde merged 4 commits into
developmentfrom
perf/skip-widget-registry-when-bridge-off
Aug 15, 2026
Merged

perf(workspace): only touch the widget registry when the bridge is on#120
rubenvdlinde merged 4 commits into
developmentfrom
perf/skip-widget-registry-when-bridge-off

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Problem

IManager::getWidgets() is not a getter. It calls loadLazyPanels(), which calls load() on every dashboard widget of every app enabled for the user, ignoring the dashboard layout — and load() is where widgets call Util::addScript(). Simply enumerating widgets injects every widget bundle in the instance.

index() reached the registry twice per render: once via loadWidgetScripts() and once via getAvailableWidgets() for the initial state.

Worth calling out, because it inverts the obvious fix: the explicit foreach ($widgets as $widget) { $widget->load(); } loop was dead weight. getWidgets() had already called load() on all of them before the loop ran, so removing just the loop would have changed nothing measurable.

Measured

Workspace page, bridge on vs off:

JS Files Widget bundles Load
bridge on 161.47 MB 72 24 36,776 ms
bridge off 42.90 MB 21 0 6,184 ms

118.6 MB and ~30s of widget code the workspace never renders — and every one of those bundles threw Cannot read properties of undefined (reading 'register'), because OCA.Dashboard only exists on /apps/dashboard.

Change

The registry is read only when legacyWidgetBridgeEnabled is set. Safe when off: Views.vue already fetches the list from GET /api/widgets on boot, and loadInitialState defaults widgets to [], so the REQ-INIT-002 reader still never returns undefined. IManager was the sole user of the dashboardManager constructor argument, so it and its import go too.

Known limitation

With the bridge on, the cost is unchanged. That path is bounded by Nextcloud core, which offers no way to read widget metadata without loadLazyPanels() injecting every script. Fixing it needs an upstream change to OC\Dashboard\Manager — filed separately.

Verification

Full gate in the container on PHP 8.4 (host CLI is 8.2, so check:strict fatals in platform_check.php without analysing anything):

  • lint: 308 files clean
  • phpcs: 0 errors — identical to the HEAD baseline for this file
  • phpstan: [OK] No errors
  • psalm: exit 0
  • phpunit: 1570 tests, 0 failures, 3 skipped
  • vitest: 640/641; the one failure (Views.loadingState.spec.js) is a 20s timeout under full-suite load and passes in isolation at 10.9s. No JavaScript is touched by this PR.

IManager::getWidgets() is not a getter. It calls loadLazyPanels(), which
calls load() on EVERY dashboard widget of every app enabled for the user,
ignoring the dashboard layout — and load() is where widgets call
Util::addScript(). Merely enumerating widgets therefore injects every
widget bundle in the instance.

index() reached the registry twice per render: once through
loadWidgetScripts() and once through getAvailableWidgets() for the initial
state. The explicit `foreach ($widgets as $widget) { $widget->load(); }`
loop in loadWidgetScripts() was dead weight — getWidgets() had already
called load() on all of them before the loop ran — so removing only that
loop would have changed nothing.

Measured on the workspace page, bridge on vs off:

  bridge on   161.47 MB JS, 72 files, 24 widget bundles, 36,776 ms
  bridge off   42.90 MB JS, 21 files,  0 widget bundles,  6,184 ms

That is 118.6 MB and ~30s of widget code the workspace never renders,
every one of which used to throw because OCA.Dashboard exists only on
/apps/dashboard.

The registry is now read only when legacyWidgetBridgeEnabled is set. The
SPA is unaffected when it is off: Views.vue already fetches the available
widget list from GET /api/widgets on boot, and loadInitialState defaults
`widgets` to [], so the REQ-INIT-002 reader still never returns undefined.

IManager was the only user of the dashboardManager constructor argument,
so both the argument and the import go with it.

With the bridge on the cost is unchanged — that path is bounded by
Nextcloud core, which offers no way to read widget metadata without
loadLazyPanels() injecting every script. Fixing that needs an upstream
change to OC\Dashboard\Manager.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/launchpad @ cf82f01

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
format
composer ✅ 104/104
npm ✅ 536/536
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-14 20:46 UTC

Download the full PDF report from the workflow artifacts.

Enabling the bridge makes the workspace read the Nextcloud widget
registry, and IManager::getWidgets() injects the scripts of every widget
of every enabled app. Measured on the workspace page that is 118.6 MB of
JS and ~30s of load for widgets the workspace never renders, so the
expensive path should be opt-in rather than the default.

With no stored setting the workspace now renders in 4,988 ms with 42.90 MB
of JS, 21 files and zero widget bundles, against 36,776 ms / 161.47 MB /
24 bundles when the bridge is on.

Instances that rely on bridged placements switch it back on in Beheer;
the flag is read on every render, so it takes effect immediately.

The stored-value test asserted false, which is the new default and would
therefore have passed without reading storage at all. It now stores true —
the opposite of the default — so it still discriminates.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/launchpad @ c3c7b95

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
format
composer ✅ 104/104
npm ✅ 536/536
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-14 21:27 UTC

Download the full PDF report from the workflow artifacts.

CI caught what the local run did not: phpmd ExcessiveMethodLength, because
index() went from 88 lines on development to 108 with this PR's guard and its
explanatory comment. The threshold is 100.

The comment was carrying most of the weight, and it documents the bridge
decision rather than the render flow, so it belongs with the logic it
explains. Extracting resolveBridgedWidgets() takes both out of index(), which
is now 90 lines.

No behaviour change: same setting, same default (off), same call to
getAvailableWidgets() only when the bridge is on.

Verified: phpmd reports no PageController violation, phpcs unchanged at 0
errors for this file, phpstan [OK] No errors, phpunit 1570 tests 0 failures.

Worth recording why this was missed — the local sweep ran lint, phpcs,
phpstan, psalm and phpunit, but not phpmd, so "gate green" was a claim about
a smaller set of checks than CI runs.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/launchpad @ 3e4d57d

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
format
composer ✅ 104/104
npm ✅ 536/536
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-15 21:02 UTC

Download the full PDF report from the workflow artifacts.

CI's coverage guard failed the previous commit — not a failing test, the
ratchet:

  Coverage current:    51.68%  (11645/22533 statements)
  Coverage merge base: 51.68%  (11645/22532 statements)
  FAIL: This change adds 1 statements. Adding code without tests drops coverage.

Extracting resolveBridgedWidgets() added one statement and covered none of
it, because PageController had no test at all. The percentage did not move —
only the counts — which is exactly the shape that reads as flake.

The decision deserved a test regardless. The assertion that matters is
`expects($this->never())` on getAvailableWidgets: the point of this change is
not that an empty array comes back, it is that the widget registry is never
touched, so no widget's load() runs and no scripts are injected. Asserting on
the return value alone would still pass if the registry were read and
discarded.

Three cases: bridge on reads the registry, bridge off never touches it, and a
missing setting defaults to off (the fail-safe direction).

Verified load-bearing by mutation rather than by passing: flipping the default
from `?? false` to `?? true` fails the third test with
"getAvailableWidgets(): array was not expected to be called"; restoring it
returns to green.

phpunit 1573 tests 0 failures (3 added), phpmd 0 PageController violations,
phpcs 0 errors on its configured scope (lib/ — tests/ is deliberately out of
scope, matching every existing test file's positional-argument style).
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/launchpad @ 9281da8

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
format
composer ✅ 104/104
npm ✅ 536/536
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-15 21:36 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 76c1432 into development Aug 15, 2026
34 of 37 checks passed
@rubenvdlinde
rubenvdlinde deleted the perf/skip-widget-registry-when-bridge-off branch August 15, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant