From ac87a7f671dd030ea6299d748fbcb777fffd9933 Mon Sep 17 00:00:00 2001 From: Wytse Vellema Date: Wed, 15 Apr 2026 20:48:40 +0200 Subject: [PATCH] Fix project config ordering for partial loaded elementSources entries Applying project config to a clean install only sorted keys by depth. This allowed `sections` changes to run before related `elementSources` entries were fully built. Resulting in the error: In ElementSources.php line 147: Undefined array key "type" Sort pending changes by top-level key first, then by depth within each group. This makes sure only complete elements are applied. --- src/services/ProjectConfig.php | 10 +++- tests/unit/services/ProjectConfigTest.php | 65 +++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/services/ProjectConfig.php b/src/services/ProjectConfig.php index beebf0d4b03..17e6d35e546 100644 --- a/src/services/ProjectConfig.php +++ b/src/services/ProjectConfig.php @@ -1486,8 +1486,16 @@ private function _getPendingChanges(?array $configData = null, bool $existsOnly unset($removedItem); - // Sort by number of dots to ensure deepest paths listed first + // Sort by top-level key first, then deepest paths first within each group. $sorter = function($a, $b) { + $aTop = explode('.', $a, 2)[0]; + $bTop = explode('.', $b, 2)[0]; + $topCmp = strcmp($aTop, $bTop); + + if ($topCmp !== 0) { + return $topCmp; + } + $aDepth = ProjectConfigHelper::pathDepth($a); $bDepth = ProjectConfigHelper::pathDepth($b); return $bDepth <=> $aDepth; diff --git a/tests/unit/services/ProjectConfigTest.php b/tests/unit/services/ProjectConfigTest.php index d35f95b1e4a..51fca9c19e6 100644 --- a/tests/unit/services/ProjectConfigTest.php +++ b/tests/unit/services/ProjectConfigTest.php @@ -9,7 +9,9 @@ use Codeception\Stub\Expected; use Craft; +use craft\elements\Category; use craft\helpers\StringHelper; +use craft\models\ProjectConfigData; use craft\models\ReadOnlyProjectConfigData; use craft\mutex\Mutex; use craft\mutex\NullMutex; @@ -216,6 +218,69 @@ public function testEventsFiredAndDeltaStored(): void $pc->saveModifiedConfigData(); } + public function testApplyingConfigChangesDoesNotExposePartialElementSourcesToSectionHandlers(): void + { + $projectConfig = new ProjectConfig(); + $internalConfig = new ReadOnlyProjectConfigData([], $projectConfig); + $currentWorkingConfig = new ProjectConfigData([], $projectConfig); + + $internalConfigProperty = new \ReflectionProperty(ProjectConfig::class, '_internalConfig'); + $internalConfigProperty->setValue($projectConfig, $internalConfig); + + $currentWorkingConfigProperty = new \ReflectionProperty(ProjectConfig::class, '_currentWorkingConfig'); + $currentWorkingConfigProperty->setValue($projectConfig, $currentWorkingConfig); + + $incomingConfig = [ + 'elementSources' => [ + 'craft\\elements\\Category' => [[ + 'defaultSort' => ['structure', 'asc'], + 'tableAttributes' => ['link'], + 'type' => 'native', + 'key' => 'group:bedad454-861f-4d7f-b0f6-40ce023cffc5', + 'disabled' => false, + ]], + ], + 'sections' => [ + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' => [ + 'siteSettings' => [ + 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' => [ + 'hasUrls' => true, + 'uriFormat' => 'test/{slug}', + 'template' => 'test/_entry', + 'enabledByDefault' => true, + ], + ], + 'entryTypes' => ['cccccccc-cccc-cccc-cccc-cccccccccccc'], + ], + ], + ]; + + $externalConfig = new ReadOnlyProjectConfigData($incomingConfig, $projectConfig); + $externalConfigProperty = new \ReflectionProperty(ProjectConfig::class, '_externalConfig'); + $externalConfigProperty->setValue($projectConfig, $externalConfig); + + $originalProjectConfig = Craft::$app->getProjectConfig(); + $originalElementSources = Craft::$app->getElementSources(); + $handlerWasCalled = false; + + Craft::$app->set('projectConfig', $projectConfig); + Craft::$app->set('elementSources', new \craft\services\ElementSources()); + + $projectConfig->onAdd(ProjectConfig::PATH_SECTIONS . '.{uid}', function() use (&$handlerWasCalled) { + $handlerWasCalled = true; + Craft::$app->getElementSources()->getSources(Category::class); + }); + + try { + $projectConfig->applyConfigChanges($incomingConfig); + } finally { + Craft::$app->set('projectConfig', $originalProjectConfig); + Craft::$app->set('elementSources', $originalElementSources); + } + + self::assertTrue($handlerWasCalled); + } + public function getConfigProvider(): array { return [