Compare commits

...
28 Commits
Author SHA1 Message Date
Laura Neto c087ce9fb5 Bump the version of the Umbraco.TheStarterKit to 18.0.0 in the UmbracoProject template 2026-06-24 11:23:37 +02:00
Jacob Overgaard 8dd8820fa3 build(deps): bumps @umbraco-ui/uui to 2.0.0 2026-06-23 11:54:00 +02:00
Laura Neto 6b76230da5 Bump version to 18.0.0. 2026-06-23 09:38:59 +02:00
Andy Butland 2e3628dad3 Bump version to 18.0.0-rc4. 2026-06-19 18:50:40 +02:00
Jacob OvergaardandClaude Opus 4.8 cb23c84c3d External login: wait for app-entry-points before the login provider decision (#23167)
* External login: wait for app-entry-points before the login provider decision

The backoffice boot stopped waiting for app-entry-point extensions to settle
before deciding which auth provider to use (regression introduced in #22522).
On a slow connection an externally registered authProvider (e.g. Umbraco ID)
is not registered yet when the login screen renders, so the user is dropped on
the local login instead of being redirected to the external provider.

- extension-initializer-base: `loaded` re-arms to `undefined` while a pass is in
  flight and resolves to `true` unconditionally (including zero extensions), so
  `.asPromise()` gates correctly and never hangs on a default install (which has
  no app-entry-points) — the reason the await was removed in the first place.
- app.element: restore the awaited boot gate before routing.

Tests:
- Unit test for the `loaded` signal contract (zero extensions resolves; a late,
  slow extension is awaited).
- Playwright acceptance test that deploys an app-entry-point registering an
  authProvider after a delay and asserts it is offered on the login screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(backoffice): guard the loaded-gate timing for permission loading

Add a test asserting the collection initializer's `loaded` does not open the
gate (`#loadedGuard` awaits it via `.asPromise()`, fronting private-extension
and user-permission loading) until the initially-registered extensions have
instantiated. Addresses the #22522 "user permissions resolved too late" concern
in writing; user-permission condition resolution itself lives in
UmbBaseExtensionInitializer (covered by base-extension-initializer.race.test.ts)
and is untouched by this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backoffice): harden loaded signal + narrow acceptance test glob (review)

Address PR review feedback:
- extension-initializer-base: only the latest processing pass settles `loaded`
  (monotonic pass id), so a slow earlier pass can't unblock waiters early when
  the async observer overlaps passes; and use `Promise.allSettled` so a throwing
  `instantiateExtension` can't leave `loaded` stuck at `undefined` (hanging the
  boot gate) — failures are logged rather than swallowed.
- playwright.config: narrow the project glob to `**/*.spec.ts` so Playwright
  doesn't try to load the App_Plugins `entry-point.js` ESM fixture as a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:28:23 +02:00
Jesper MadsenandJacob Overgaard 1dfcd9332a Let the external login button show "sign in with {providername}" in languages (#23135) 2026-06-17 15:57:40 +02:00
Jacob OvergaardandClaude Opus 4.8 d84186061c fix(tests): point DomainCacheServiceTests mocks at GetAllAsync
IDomainService.GetAll was removed in #22629; DomainCacheServiceTests was
added later in #23084 against a stale base and still mocked the removed
method, breaking the Release build on release/18.0. Production
DomainCacheService already calls GetAllAsync, so update the four mock
setups to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:52:52 +02:00
Jacob Overgaard 87bc6522f1 build: deploy to npm through a template 2026-06-17 14:26:25 +02:00
Jacob OvergaardandClaude Opus 4.7 c300ebf94a Build: tag prerelease npm publishes with 'next' dist-tag (#22909)
* Build: tag prerelease npm publishes with 'next' dist-tag

Prereleases that flow through Deploy_Npm (e.g. 18.0.0-beta1) currently
land on the `latest` dist-tag, so a bare `npm install @umbraco-cms/backoffice`
resolves to an unstable build. Switch to `--tag next` when
NBGV_PrereleaseVersion is non-empty, leaving `latest` for stable releases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Build: address review feedback on npm prerelease dist-tag

- Add Build to Deploy_Npm dependsOn so stageDependencies.Build.A.outputs
  resolves explicitly (mirrors the Upload_API_Docs pattern).
- Pass npmPrereleaseVersion via env: instead of inline macro expansion in
  bash, so an unset variable won't be interpreted as command substitution.

* Build: source npmPrereleaseVersion via dependencies, not dependsOn

Switches the variable mapping from stageDependencies (which needs Build
in dependsOn) to dependencies.Build.outputs[...], matching the pattern
the stage's condition already uses on line 941. Avoids drawing a
redundant parallel arrow from Build to Deploy_Npm in the ADO stage
graph — Build is already in the ancestor chain via Deploy_NuGet.

* Build: align Deploy_Npm with Umbraco Deploy publish pattern

- Use stageDependencies form in variables: (dependencies.* only works in conditions).
- Source NBGV_PrereleaseVersionNoLeadingHyphen for a cleaner check.
- Replace echo >> .npmrc with npm config set --location=project.
- Collapse if/else into a tag=latest|next shell variable; single npm publish *.tgz.
- Drop unnecessary env: passthrough and npm init -y.

Per Ronald's feedback on PR #22909 — mirrors the Deploy pipeline's release stage.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 14:00:19 +02:00
Ronald BarendseandAndy Butland e2252a8634 Tests: Remove AutoFixture from the shipped Umbraco.Cms.Tests package (#23141)
Tests: Remove AutoFixture from Umbraco.Cms.Tests package
2026-06-17 09:50:01 +02:00
Andy Butland 37e2458475 Merge branch 'release/18.0' of https://github.com/umbraco/Umbraco-CMS into release/18.0 2026-06-15 16:20:46 +02:00
Andy Butland 2461853b11 Published Cache: Fix multi-site domains falling back to the first root node after restart (#23084)
* Prevent empty domain cache during concurrent initialization.

* Addressed code review comments and added further comment to the code.

* Use Lock object.
2026-06-15 16:20:11 +02:00
16c97d613f Elements: Invalidate the id/key map when an element container is deleted (closes #23072) (#23074)
* Refresh the element container cache on delete to ensure the id/key map is invalidated.

* Rename and additional asserts in test.

* Use a dedicated refresher for element container id/key map eviction

Routing container-delete invalidation through ElementCacheRefresher cleared
the entire elements cache on every payload, so deleting a container triggered
a full clear even though no element data changed (and a second clear on top of
the ElementTreeChangeNotification refresh when the container held elements).

Add a dedicated ElementContainerCacheRefresher whose only job is to evict the
container's IIdKeyMap entry, and route EntityContainerDeletedNotification
through it instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Addressed code review feedback.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:16:31 +02:00
Andy Butland cffac2a990 Dependencies: Update MessagePack to 3.1.7 to address security advisories (#23113)
Update MessagePack dependency to 3.1.7.
2026-06-15 06:37:13 +02:00
Kenn JacobsenandGitHub 6883c6fcfd Tags: Expand ITagService to handle Elements (#23117) 2026-06-15 06:28:52 +02:00
Andy ButlandandGitHub 5dd28e7a13 Tests: Fix failing Management API integration tests (closes #23076) (#23081)
* Include currently missing management API tests in the CI build.

* Fixed failing tests.

* Revert the pipeline updates.

* Addressed code review feedback.
2026-06-07 08:40:23 +02:00
Niels LyngsøandGitHub 87b24912c7 V18: Beta UI adjustments (#22869) 2026-06-05 15:10:54 +02:00
Andy Butland f6c70e8429 Bump version to 18.0.0-rc3. 2026-06-05 06:46:44 +02:00
Laura NetoandGitHub 1dbcf1037a Delivery API - Open API: return inline {} schema for unconstrained property types (#23066)
* Delivery API: return inline {} schema for unconstrained property types

ContentTypeSchemaTransformer now checks the raw STJ schema via JsonSchemaExporter before
calling GetOrCreateSchemaAsync. STJ generates boolean true for unconstrained types (JsonNode,
object, types with custom converters), which the pipeline converts to {}. When the raw schema
is true, an inline {} is returned without registering a named component - a named component
adds no value and misleads API consumers into thinking a concrete model shape exists.

* Delivery API: add Plain JSON property to contract test sample types

Adds a Plain JSON property to the sample article page content type used by the OpenAPI contract
tests. This exercises the unconstrained-type fix: the property should appear as inline {} in the
schema, not as a named JsonNode component. Updates the expected contract to reflect the new
property.

* Re-generate typed-schemas-with-sample-types.json

For some reason the previous change got formatted differently, so it was displaying more changes than it should.

* Simplify comments

* Delivery API: guard unconstrained type check with JsonTypeInfoKind.None
2026-06-04 17:01:49 +02:00
Andy ButlandandGitHub 27909ed18e Elements: Hide element actions from the document notifications dialog (closes #23053) (#23059)
Remove element actions from the notification dialog.
2026-06-04 14:57:32 +02:00
Andy Butland 54827c4c92 Background Jobs: Resolve server role so recurring jobs run when no application URL is configured (#23033)
Resolve server role when no application URL is configured.
2026-06-04 06:44:51 +02:00
Laura NetoandGitHub 214fd03241 OpenAPI: Disable XML documentation source generator (closes #23018) (#23045)
Disable OpenAPI XML documentation source generator

Due to the amount of XML documentation in this solution, the OpenAPI XML documentation source generator produces too many lines of code in a single method (GenerateCacheEntries), which causes a StackOverflowException when running on IIS. The fix disables the analyzer globally via Directory.Build.props.
2026-06-03 12:28:51 +02:00
Jacob OvergaardandGitHub 787f66be3d Dependencies: Bumps @umbraco-ui/uui from 2.0.0-rc.1 to 2.0.0-rc.2 (#23052)
build(deps): bumps @umbraco-ui/uui from 2.0.0-rc.1 to 2.0.0-rc.2
2026-06-03 09:00:01 +00:00
Erik-Jan WestendorpandAndy Butland cd23fc75c5 Localisation: Translate the "Library" section header into other languages (#23043)
* Translate library to Dutch and Spanish

* Add translations for other cultures.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-03 09:36:43 +02:00
Sven GeusensandAndy Butland 0adb5d21f5 Developer Experience: Improve cohost editor polyfill to work with dotnet watch (closes #22773) (#22999)
* Improve cohost polyfill

* Apply suggestions from code review

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Move <target/> part of the polyfill to targets file.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-01 14:40:54 +02:00
Andy ButlandandGitHub 446a3795b7 Elements: Clear user and user group start node references when deleting an element container (closes #23010) (#23011)
* Clear user and user group start nodes when deleting an element container.

* Assert user start node references cleared after container delete

Mirrors the post-delete assertion already present in the user group
sibling test so both tests confirm the reference was cleaned up, not
just that no FK exception was thrown.

* Guard against null entity in PersistDeletedItem override

Mirrors the ArgumentNullException guard in the base
EntityContainerRepository.PersistDeletedItem so a null argument throws
the same exception type.
2026-06-01 10:36:11 +02:00
Andy ButlandandGitHub 3f0e0747fa Management API: Declare multipart/form-data on the Create Temporary File endpoint (closes #23017) (#23025)
Add explicit Consumes to management API endpoint that accepts IFormFile.
2026-06-01 09:32:48 +02:00
Laura Neto f3471e961f Bump version to 18.0.0-rc2 2026-05-28 09:56:34 +02:00
109 changed files with 1650 additions and 357 deletions
+10
View File
@@ -64,4 +64,14 @@
</_ProjectReferencesWithVersions>
</ItemGroup>
</Target>
<!-- Workaround for https://github.com/umbraco/Umbraco-CMS/issues/23018
Due to the amount of XML documentation in this solution, the OpenAPI XML documentation source generator produces
too many lines of code causing a StackOverflowException when running on IIS. For that reason we disable the analyzer.
See https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/openapi-comments?view=aspnetcore-10.0#disabling-xml-documentation-support -->
<Target Name="DisableCompileTimeOpenApiXmlGenerator" BeforeTargets="CoreCompile" Condition="'$(IsPackable)' != 'false' or '$(IsTestProject)' == 'true'">
<ItemGroup>
<Analyzer Remove="@(Analyzer)" Condition="'%(Filename)' == 'Microsoft.AspNetCore.OpenApi.SourceGenerators'" />
</ItemGroup>
</Target>
</Project>
+1 -1
View File
@@ -57,7 +57,7 @@
<PackageVersion Include="MailKit" Version="4.16.0" />
<PackageVersion Include="Markdig" Version="1.1.3" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
<PackageVersion Include="ncrontab" Version="3.4.0" />
+32 -98
View File
@@ -825,74 +825,31 @@ stages:
publishFeedCredentials: "MyGet - Umbraco Nightly"
${{ else }}:
publishFeedCredentials: "MyGet - Pre-releases"
# Pre-release/nightly feeds: keep the `latest` dist-tag default (no `next` split).
- job:
displayName: Push to pre-release feed (npm)
steps:
- checkout: none
- download: current
artifact: npm
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm/.npmrc"
- template: templates/npm-publish.yml
parameters:
artifactName: npm
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
- bash: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Push to npm (MyGet)
${{ if eq(parameters.isNightly, true) }}:
registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
registry: https://www.myget.org/F/umbracoprereleases/npm/
- job: PublishTestHelpersNpm
displayName: Push TestHelpers to pre-release feed (npm)
steps:
- checkout: none
- download: current
artifact: npm-testhelpers
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm-testhelpers/.npmrc"
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
- bash: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push test helpers to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Push test helpers to npm (MyGet)
${{ if eq(parameters.isNightly, true) }}:
registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
registry: https://www.myget.org/F/umbracoprereleases/npm/
- stage: Deploy_NuGet
displayName: NuGet release
@@ -941,53 +898,30 @@ stages:
condition: and(in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
dependsOn:
- Deploy_NuGet
variables:
# `latest` for stable releases, `next` for prereleases.
npmDistTag: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'], ''), 'latest', 'next') ]
jobs:
- job: Publish
displayName: Push to NPM
steps:
- checkout: none
- download: current
artifact: npm
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm/.npmrc
- template: templates/npm-publish.yml
parameters:
artifactName: npm
registry: https://registry.npmjs.org/
customEndpoint: "NPM - Umbraco Backoffice"
- script: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push to npm
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Push to npm
npmTag: $(npmDistTag)
- job: PublishTestHelpers
displayName: Push Test Helpers to NPM
steps:
- checkout: none
- download: current
artifact: npm-testhelpers
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm-testhelpers/.npmrc
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
registry: https://registry.npmjs.org/
customEndpoint: "NPM - Umbraco Backoffice"
- script: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push test helpers to npm
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Push test helpers to npm
npmTag: $(npmDistTag)
- stage: Upload_API_Docs
pool:
+28
View File
@@ -0,0 +1,28 @@
parameters:
- name: artifactName # "npm" or "npm-testhelpers"
type: string
- name: registry # scoped-registry URL to publish to
type: string
- name: customEndpoint # npmAuthenticate service connection(s)
type: string
- name: displayName # label for the publish step
type: string
- name: npmTag # dist-tag to publish under
type: string
default: latest
steps:
- checkout: none
- download: current
artifact: ${{ parameters.artifactName }}
- script: npm config set @umbraco-cms:registry ${{ parameters.registry }} --location=project
displayName: Add scoped registry to .npmrc
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/${{ parameters.artifactName }}/.npmrc
customEndpoint: ${{ parameters.customEndpoint }}
- script: npm publish *.tgz --tag ${{ parameters.npmTag }}
displayName: ${{ parameters.displayName }}
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
@@ -1,5 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
using System.Text.Json.Serialization.Metadata;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.OpenApi;
@@ -341,6 +343,12 @@ public sealed class ContentTypeSchemaTransformer : IOpenApiSchemaTransformer, IO
var schemaId = GetSchemaId(jsonTypeInfo);
// Types that produce 'true' in JSON Schema (unconstrained: JsonNode, object, custom-converter types) should be inline {} rather than named components.
if (jsonTypeInfo.Kind == JsonTypeInfoKind.None && jsonTypeInfo.GetJsonSchemaAsNode().GetValueKind() == JsonValueKind.True)
{
return new OpenApiSchema();
}
// If this is one of the types we handle, and we already started generating it, return a placeholder
// to avoid circular reference issues.
// In the document transformer, these placeholders will be replaced with the actual schemas.
@@ -32,6 +32,7 @@ public class CreateTemporaryFileController : TemporaryFileControllerBase
[HttpPost("")]
[MapToApiVersion("1.0")]
[Consumes("multipart/form-data")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Creates a temporary file.")]
+1 -1
View File
@@ -33430,7 +33430,7 @@
"operationId": "PostTemporaryFile",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
@@ -16,21 +16,16 @@
</ItemGroup>
<!--
The Razor editor in VS2026 and the C# extension for VS Code uses the Razor source generator
The Razor editor in modern Visual Studio and the C# extension for VS Code use the Razor source generator
for IDE functionality. We need to add some things to make sure it works correctly, but we
only do them for design time builds, so that we don't impact regular builds or CI.
We also have an escape hatch in case it does cause issues, users can set the appropriate property
We also have an escape hatch in case it does cause issues, users can set EnableCohostEditorCompatibility=false
in their project file to disable this.
CompilerVisibleProperty is surfaced to generators via AnalyzerConfigOptionsProvider, not as a source-generator input file,
so it doesn't enter the hintName-collision codepath that AdditionalFiles does. Keeping it at evaluation time is safe.
-->
<ItemGroup Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
<!--
We have to make sure the source generator can see the .cshtml files, so make them AdditionalFiles.
-->
<AdditionalFiles Include="**\*.cshtml" />
<!--
Make sure the source generator knows where the project is, so it can compute target paths.
-->
<CompilerVisibleProperty Include="MSBuildProjectDirectory" />
</ItemGroup>
</Project>
@@ -49,4 +49,39 @@
<ContentWithTargetPath Include="@(_UmbracoFolderFiles)" Exclude="@(ContentWithTargetPath)" TargetPath="%(Identity)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Target>
<!--
The Razor source generator needs .cshtml files in @(AdditionalFiles). The Razor SDK adds them
via @(RazorGenerate), but only inside a target that runs during the build — so during cohost
design-time builds they may not be present yet, which is what PR #21861 worked around.
Doing the include at evaluation time (as PR #21861 did) causes duplicates with the SDK during
dotnet watch / hot reload design-time builds: the SDK adds the same .cshtml under a different
item Identity (slash form / relative vs absolute) and the generator then sees two inputs that
derive the same hintName, which crashes it with CS8785 (see issue #22773).
Run as a target before CoreCompile (hot-reload path) and CompileDesignTime (IDE design-time path)
so the SDK's contribution is visible in both cases. Then add only the .cshtml files that are not already
present. Both sides are normalized to %(FullPath) so items with different Identity forms still compare equal.
Set EnableCohostEditorCompatibility=false in a project to opt out entirely.
-->
<Target Name="_UmbracoEnsureRazorAdditionalFilesForCohostEditor"
BeforeTargets="CoreCompile;CompileDesignTime"
Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
<ItemGroup>
<_UmbracoCshtmlCandidate Include="**\*.cshtml" />
<_UmbracoCshtmlCandidateFull Include="@(_UmbracoCshtmlCandidate->'%(FullPath)')" />
<_UmbracoExistingAdditionalCshtmlFull
Include="@(AdditionalFiles->'%(FullPath)')"
Condition="'%(Extension)' == '.cshtml'" />
<_UmbracoCshtmlMissingFromAdditional
Include="@(_UmbracoCshtmlCandidateFull)"
Exclude="@(_UmbracoExistingAdditionalCshtmlFull)" />
<AdditionalFiles Include="@(_UmbracoCshtmlMissingFromAdditional)" />
</ItemGroup>
</Target>
</Project>
@@ -21,7 +21,7 @@ public class ActionElementContainerDelete : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementContainerMove : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementContainerNew : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementContainerUpdate : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementCopy : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementDelete : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementMove : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
+1 -1
View File
@@ -21,7 +21,7 @@ public class ActionElementNew : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementPublish : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementRollback : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementUpdate : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -467,6 +467,20 @@ public static class DistributedCacheExtensions
#endregion
#region ElementContainerCacheRefresher
/// <summary>
/// Invalidates the id/key map for the specified deleted element containers (folders).
/// </summary>
/// <param name="dc">The distributed cache.</param>
/// <param name="deletedContainers">The element containers that were deleted.</param>
public static void RemoveElementContainerCache(this DistributedCache dc, IEnumerable<EntityContainer> deletedContainers)
=> dc.RefreshByPayload(
ElementContainerCacheRefresher.UniqueId,
deletedContainers.Select(container => new ElementContainerCacheRefresher.JsonPayload(container.Id, container.Key)));
#endregion
#region Published Snapshot
/// <summary>
@@ -0,0 +1,43 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Invalidates element caches when an element container (folder) is deleted, so that its key→id mapping
/// is evicted from <see cref="Services.IIdKeyMap"/> on every server.
/// </summary>
/// <remarks>
/// Element container deletions only publish <see cref="EntityContainerDeletedNotification"/> and an
/// <see cref="ElementTreeChangeNotification"/> for the contained elements - never for the container node
/// itself, so without this handler the container's stale id/key mapping survives until the next app
/// restart (see #23072).
/// </remarks>
public sealed class ElementContainerDeletedDistributedCacheNotificationHandler
: DeletedDistributedCacheNotificationHandlerBase<EntityContainer, EntityContainerDeletedNotification>
{
private readonly DistributedCache _distributedCache;
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/> class.
/// </summary>
/// <param name="distributedCache">The distributed cache.</param>
public ElementContainerDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache)
=> _distributedCache = distributedCache;
/// <inheritdoc />
protected override void Handle(IEnumerable<EntityContainer> entities, IDictionary<string, object?> state)
{
EntityContainer[] elementContainers = entities
.Where(container => container.ContainerObjectType == Constants.ObjectTypes.ElementContainer)
.ToArray();
if (elementContainers.Length == 0)
{
return;
}
_distributedCache.RemoveElementContainerCache(elementContainers);
}
}
@@ -0,0 +1,109 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Provides cache refresh functionality for element containers (folders).
/// </summary>
/// <remarks>
/// A deleted container's node id is never reused, so its key→id mapping in <see cref="IIdKeyMap"/> must be
/// evicted on every server. Otherwise a container recreated under the same key resolves to the stale id and
/// the element tree's children query returns nothing until the next app restart. This refresher only evicts
/// the id/key map - element data is unaffected by container changes, so it deliberately avoids the broader
/// invalidation performed by <see cref="ElementCacheRefresher"/>.
/// </remarks>
public sealed class ElementContainerCacheRefresher : PayloadCacheRefresherBase<ElementContainerCacheRefresherNotification, ElementContainerCacheRefresher.JsonPayload>
{
private readonly IIdKeyMap _idKeyMap;
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresher"/> class.
/// </summary>
public ElementContainerCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
IIdKeyMap idKeyMap,
IEventAggregator eventAggregator,
ICacheRefresherNotificationFactory factory)
: base(appCaches, serializer, eventAggregator, factory)
=> _idKeyMap = idKeyMap;
#region Json
/// <summary>
/// Represents a JSON-serializable payload identifying an element container that changed.
/// </summary>
public class JsonPayload
{
/// <summary>
/// Initializes a new instance of the <see cref="JsonPayload"/> class.
/// </summary>
/// <param name="id">The unique integer identifier for the container.</param>
/// <param name="key">The unique GUID key associated with the container.</param>
public JsonPayload(int id, Guid key)
{
Id = id;
Key = key;
}
/// <summary>
/// Gets the unique integer identifier for the container.
/// </summary>
public int Id { get; }
/// <summary>
/// Gets the unique GUID key associated with the container.
/// </summary>
public Guid Key { get; }
}
#endregion
#region Define
/// <summary>
/// Represents a unique identifier for the cache refresher.
/// </summary>
public static readonly Guid UniqueId = Guid.Parse("9C9D8B0E-2F1A-4D63-9C2E-7E6B5A4F3C21");
/// <inheritdoc/>
public override Guid RefresherUniqueId => UniqueId;
/// <inheritdoc/>
public override string Name => "Element Container Cache Refresher";
#endregion
#region Refresher
/// <inheritdoc/>
public override void Refresh(JsonPayload[] payloads)
{
foreach (JsonPayload payload in payloads)
{
// Clearing by id also evicts the key→id direction, as the id/key map keeps both in sync.
_idKeyMap.ClearCache(payload.Id);
}
base.Refresh(payloads);
}
// These events should never trigger. Everything should be PAYLOAD/JSON.
/// <inheritdoc/>
public override void RefreshAll() => throw new NotSupportedException();
/// <inheritdoc/>
public override void Refresh(int id) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Refresh(Guid id) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Remove(int id) => throw new NotSupportedException();
#endregion
}
@@ -405,7 +405,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -454,7 +454,8 @@
<key alias="httpsCheckConfigurationRectifyNotPossible">Mae gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i 'false' yn eich ffeil appSettings.json. Unwaith y byddwch yn cyrchu'r wefan hon gan ddefnyddio'r cynllun HTTPS, dylid gosod hwnnw i 'true'.</key>
<key alias="httpsCheckConfigurationCheckResult">Mae'r gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i '%0%' yn eich ffeil appSettings.json, mae eich cwcis %1% wedi'u marcio'n ddiogel.</key>
<key alias="umbracoApplicationUrlCheckResultTrue">Mae gosodiad yr ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod i <strong>%0%</strong>.</key>
<key alias="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod, felly bydd URL y rhaglen yn cael ei ganfod yn awtomatig o geisiadau sy'n dod i mewn. Argymhellir ei osod yn benodol.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod ac mae canfod URL y rhaglen yn awtomatig wedi'i analluogi (mae 'Umbraco:CMS:WebRouting:ApplicationUrlDetection' yn 'None'). Ni fydd nodweddion sydd angen URL absoliwt, fel e-byst ailosod cyfrinair a gwahoddiadau, yn gweithio. Gosodwch URL y rhaglen yn benodol, neu galluogwch ganfod yn awtomatig.]]></key>
<key alias="smtpMailSettingsNotFound">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp'.</key>
<key alias="smtpMailSettingsHostNotConfigured">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp:Host'.</key>
<key alias="smtpMailSettingsConnectionFail">Methwyd cyrraedd y gweinydd SMTP a ffurfweddwyd gyda gwesteiwr '%0%' a phorth '%1%'. Gwiriwch i sicrhau bod y gosodiadau SMTP yn y ffurfweddiad 'Umbraco:CMS:Global:Smtp' yn gywir.</key>
@@ -463,7 +463,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -452,7 +452,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<key alias="clickJackingCheckHeaderFound">
<![CDATA[The header or meta-tag <strong>X-Frame-Options</strong> used to control whether a site can be IFRAMEd by another was found.]]></key>
<key alias="clickJackingCheckHeaderNotFound">
@@ -403,7 +403,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -44,28 +44,34 @@ public class UmbracoApplicationUrlCheck : HealthCheck
private HealthCheckStatus CheckUmbracoApplicationUrl()
{
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
var url = settings.UmbracoApplicationUrl;
string resultMessage;
StatusResultType resultType;
var success = false;
if (url.IsNullOrWhiteSpace())
if (url.IsNullOrWhiteSpace() is false)
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", [url]);
resultType = StatusResultType.Success;
}
else if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None)
{
// No explicit URL and auto-detection is disabled, so the application URL can never be established.
// Features that require an absolute URL (e.g. password reset and invitation emails) will not work.
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultError");
resultType = StatusResultType.Error;
}
else
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
resultType = StatusResultType.Success;
success = true;
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
}
return new HealthCheckStatus(resultMessage)
{
ResultType = resultType,
ReadMoreLink = success
ReadMoreLink = resultType == StatusResultType.Success
? null
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
};
@@ -24,4 +24,9 @@ public enum TaggableObjectTypes
/// Represents member entities (user accounts).
/// </summary>
Member,
/// <summary>
/// Represents element entities.
/// </summary>
Element,
}
@@ -0,0 +1,19 @@
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// A notification that is used to trigger the Element Container Cache Refresher.
/// </summary>
public class ElementContainerCacheRefresherNotification : CacheRefresherNotification
{
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresherNotification"/> class.
/// </summary>
/// <param name="messageObject">The refresher payload.</param>
/// <param name="messageType">Type of the cache refresher message, <see cref="MessageType"/>.</param>
public ElementContainerCacheRefresherNotification(object messageObject, MessageType messageType)
: base(messageObject, messageType)
{
}
}
+18
View File
@@ -54,6 +54,18 @@ public interface ITagService : IService
/// </summary>
IEnumerable<TaggedEntity> GetTaggedMembersByTag(string tag, string? group = null, string? culture = null);
/// <summary>
/// Gets all elements tagged with any tag in the specified group.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null) => [];
/// <summary>
/// Gets all elements tagged with the specified tag.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null) => [];
/// <summary>
/// Gets all tags.
/// </summary>
@@ -100,6 +112,12 @@ public interface ITagService : IService
/// </summary>
IEnumerable<ITag> GetAllMemberTags(string? group = null, string? culture = null);
/// <summary>
/// Gets all element tags.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null) => [];
/// <summary>
/// Gets all tags attached to an entity via a property.
/// </summary>
+27
View File
@@ -101,6 +101,24 @@ public class TagService : RepositoryService, ITagService
}
}
/// <inheritdoc />
public IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTaggedEntitiesByTagGroup(TaggableObjectTypes.Element, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTaggedEntitiesByTag(TaggableObjectTypes.Element, tag, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetAllTags(string? group = null, string? culture = null)
{
@@ -162,6 +180,15 @@ public class TagService : RepositoryService, ITagService
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTagsForEntityType(TaggableObjectTypes.Element, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetTagsForProperty(int contentId, string propertyTypeAlias, string? group = null, string? culture = null)
{
@@ -84,8 +84,19 @@ public class TouchServerJob : RecurringBackgroundJobBase
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
if (string.IsNullOrWhiteSpace(serverAddress))
{
_logger.LogWarning("No umbracoApplicationUrl for service (yet), skip.");
return Task.CompletedTask;
// No application URL is known yet: either detection is off (WebRouting:ApplicationUrlDetection is
// None with no UmbracoApplicationUrl set), or detection is on but no request has been served yet.
// Register with the machine name as a placeholder so server-role election can still proceed (uniqueness
// comes from the server identity, not this address). If a URL is later detected from a request, the next
// touch overwrites the placeholder.
serverAddress = Environment.MachineName;
_logger.LogDebug(
"No application URL available; registering server with placeholder address {ServerAddress}.",
serverAddress);
}
else
{
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
}
try
@@ -466,6 +466,7 @@ public static partial class UmbracoBuilderExtensions
.AddNotificationHandler<MemberTypeChangedNotification, MemberTypeChangedDistributedCacheNotificationHandler>()
.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>()
.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>()
.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>()
;
// add notification handlers for auditing
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Infrastructure.Scoping;
@@ -34,4 +35,27 @@ internal sealed class ElementContainerRepository : EntityContainerRepository, IE
cacheSyncService)
{
}
protected override void PersistDeletedItem(EntityContainer entity)
{
if (entity == null)
{
throw new ArgumentNullException(nameof(entity));
}
// Element containers can be referenced as start nodes on individual users (umbracoUserStartNode)
// and on user groups (umbracoUserGroup.startElementId). Both reference umbracoNode.id via FK,
// so we must clear those references before deleting the underlying node.
var args = new { id = entity.Id };
Database.Execute(
$"DELETE FROM {QuoteTableName(Constants.DatabaseSchema.Tables.UserStartNode)} WHERE {QuoteColumnName("startNode")} = @id",
args);
Database.Execute(
$@"UPDATE {QuoteTableName(Constants.DatabaseSchema.Tables.UserGroup)}
SET {QuoteColumnName("startElementId")} = NULL
WHERE {QuoteColumnName("startElementId")} = @id",
args);
base.PersistDeletedItem(entity);
}
}
@@ -647,6 +647,8 @@ ON (tagset.tag = {cmsTags}.tag AND tagset.{group} = {cmsTags}.{group} AND COALES
return Constants.ObjectTypes.Media;
case TaggableObjectTypes.Member:
return Constants.ObjectTypes.Member;
case TaggableObjectTypes.Element:
return Constants.ObjectTypes.Element;
default:
throw new ArgumentOutOfRangeException(nameof(type));
}
@@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
@@ -9,23 +9,41 @@ using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
/// <summary>
/// Implements <see cref="IDomainCacheService" />, providing an in-memory cache of the configured <see cref="Domain" />s.
/// </summary>
/// <remarks>
/// The cache is lazily populated from the database on first access and kept up to date in response to domain
/// cache refresher notifications. It is registered as a singleton, so a single instance serves all requests.
/// </remarks>
public class DomainCacheService : IDomainCacheService
{
private readonly IDomainService _domainService;
private readonly ICoreScopeProvider _coreScopeProvider;
private readonly ConcurrentDictionary<int, Domain> _domains;
private bool _initialized = false;
private readonly Lock _initializationLock = new();
// Both fields are written under _initializationLock but read on the hot path (request routing) without
// it. Marking them volatile makes those lock-free reads acquire-reads, so a reader is guaranteed to see
// the fully populated dictionary and the completed-initialization flag together, never a stale or
// half-published value. This is required for correctness on weak memory models such as ARM; on x86/x64
// ordinary reads already have acquire semantics, but we cannot rely on that.
private volatile ConcurrentDictionary<int, Domain> _domains = new();
private volatile bool _initialized;
/// <summary>
/// Initializes a new instance of the <see cref="DomainCacheService" /> class.
/// </summary>
/// <param name="domainService">The service used to load domains from the database.</param>
/// <param name="coreScopeProvider">The provider used to create scopes for database access.</param>
public DomainCacheService(IDomainService domainService, ICoreScopeProvider coreScopeProvider)
{
_domainService = domainService;
_coreScopeProvider = coreScopeProvider;
_domains = new ConcurrentDictionary<int, Domain>();
}
/// <inheritdoc />
public IEnumerable<Domain> GetAll(bool includeWildcards)
{
InitializeIfMissing();
@@ -34,22 +52,38 @@ public class DomainCacheService : IDomainCacheService
: _domains.Select(x => x.Value).OrderBy(x => x.SortOrder);
}
/// <summary>
/// Loads the domains on first access, ensuring the cache is populated before any caller reads from it.
/// </summary>
private void InitializeIfMissing()
{
// Lazy, on-demand initialization triggered by the first request to reach the cache.
// The flag must only be set to true *after* the domains have been loaded and published.
// Setting it beforehand creates a window where a concurrent caller observes _initialized == true,
// skips loading, and reads an empty domain cache. On a multi-site setup that empties domain
// resolution, causing every site to fall back to the first root node (see ContentFinderByUrlNew).
// The double-checked lock ensures a single load while concurrent readers block until it completes.
if (_initialized)
{
return;
}
_initialized = true;
LoadDomains();
lock (_initializationLock)
{
if (_initialized)
{
return;
}
LoadDomains();
_initialized = true;
}
}
/// <inheritdoc />
public IEnumerable<Domain> GetAssigned(int documentId, bool includeWildcards = false)
{
InitializeIfMissing();
// probably this could be optimized with an index
// but then we'd need a custom DomainStore of some sort
IEnumerable<Domain> list = _domains.Values.Where(x => x.ContentId == documentId);
if (includeWildcards == false)
{
@@ -66,6 +100,7 @@ public class DomainCacheService : IDomainCacheService
return documentId > 0 && GetAssigned(documentId, includeWildcards).Any();
}
/// <inheritdoc />
public void Refresh(DomainCacheRefresher.JsonPayload[] payloads)
{
foreach (DomainCacheRefresher.JsonPayload payload in payloads)
@@ -102,20 +137,23 @@ public class DomainCacheService : IDomainCacheService
continue; // anomaly
}
var newDomain = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
// Feels wierd to use key and oldvalue, but we're using neither when updating.
_domains.AddOrUpdate(
domain.Id,
new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder),
(key, oldValue) => newDomain);
_domains[domain.Id] = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
break;
}
}
}
/// <summary>
/// Reads the configured domains from the database into a fresh dictionary and atomically swaps it in
/// as the current cache.
/// </summary>
private void LoadDomains()
{
// Build the replacement set in a local dictionary and publish it with a single write to the
// (volatile) _domains field. A reader never observes a partially populated cache during a RefreshAll
// rebuild, and the published set contains exactly the current domains (any removed since the last
// load are absent).
var newDomains = new ConcurrentDictionary<int, Domain>();
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
{
scope.ReadLock(Constants.Locks.Domains);
@@ -124,11 +162,11 @@ public class DomainCacheService : IDomainCacheService
.Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false)
.Select(x => new Domain(x.Id, x.DomainName, x.RootContentId!.Value, x.LanguageIsoCode!, x.IsWildcard, x.SortOrder)))
{
_domains.AddOrUpdate(domain.Id, domain, (key, oldValue) => domain);
newDomains[domain.Id] = domain;
}
scope.Complete();
}
_domains = newDomains;
}
}
+1 -1
View File
@@ -148,7 +148,7 @@ Why: Normalizes to conventional semver format
"peerDependencies": {
"lit": "^3.3.1",
"rxjs": "^7.8.2",
"@umbraco-ui/uui": "^2.0.0-alpha.1",
"@umbraco-ui/uui": "^2.0.0",
"monaco-editor": "^0.55.1",
"@tiptap/core": "^3.16.0",
"@hey-api/openapi-ts": ">=0.85.0 <1.0.0"
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "@umbraco-cms/backoffice",
"version": "18.0.0-rc1",
"version": "18.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@umbraco-cms/backoffice",
"version": "18.0.0-rc1",
"version": "18.0.0",
"license": "MIT",
"workspaces": [
"./src/libs/*",
@@ -3963,9 +3963,9 @@
"link": true
},
"node_modules/@umbraco-ui/uui": {
"version": "2.0.0-rc.1",
"resolved": "https://registry.npmjs.org/@umbraco-ui/uui/-/uui-2.0.0-rc.1.tgz",
"integrity": "sha512-qhKsTl11hq82GQWZEq/2f/D1UePjfN9DLet7MqastvEKFdwdgHM9cCu+2f3mlOcX/OHj5XiUwLVFoJanRgfJZg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@umbraco-ui/uui/-/uui-2.0.0.tgz",
"integrity": "sha512-snH3u1C4gpvO/bxTfTJV6lF3HVpru5v0ssMbz0aESOt66gRyGf//fshgUHgvYa5gc3wE2Fr4uGx8mKgUC/GrXQ==",
"license": "MIT",
"dependencies": {
"culori": "^4.0.2",
@@ -16447,7 +16447,7 @@
"src/external/uui": {
"name": "@umbraco-backoffice/uui",
"dependencies": {
"@umbraco-ui/uui": "^2.0.0-rc.1"
"@umbraco-ui/uui": "^2.0.0"
}
},
"src/libs/class-api": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@umbraco-cms/backoffice",
"license": "MIT",
"version": "18.0.0-rc1",
"version": "18.0.0",
"type": "module",
"exports": {
".": null,
@@ -262,7 +262,7 @@ export class UmbAppElement extends UmbLitElement {
// Register public extensions (login extensions)
await new UmbServerExtensionRegistrator(this, umbExtensionsRegistry).registerPublicExtensions();
new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
const entryPointInitializer = new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
// Try to initialise the auth flow and get the runtime status
try {
@@ -278,6 +278,12 @@ export class UmbAppElement extends UmbLitElement {
await this.#setAuthStatus();
}
// The login screen decides which auth provider to use from the registered
// `authProvider` extensions. App-entry-points may register or unregister those during
// their async onInit, so wait for them to settle before routing — otherwise on a slow
// connection the decision races and falls back to the local login.
await this.observe(entryPointInitializer.loaded).asPromise();
// Initialise the router
this.#redirect();
} catch (error) {
@@ -1258,6 +1258,7 @@ export default {
},
sections: {
content: 'المحتوى',
library: 'المكتبة',
media: 'الوسائط',
member: 'الأعضاء',
packages: 'الحزم',
@@ -1184,20 +1184,13 @@ export default {
editscript: 'Uredite datoteku skripte',
},
sections: {
concierge: 'Portirnica',
content: 'Sadržaj',
courier: 'Kurir',
developer: 'Developer',
forms: 'Forme',
help: 'Pomoć',
installer: 'Umbraco Konfiguracijski Čarobnjak',
library: 'Biblioteka',
media: 'Mediji',
member: 'Članovi',
newsletters: 'Bilteni',
packages: 'Paketi',
marketplace: 'Marketplace',
settings: 'Postavke',
statistics: 'Statistika',
translation: 'Prevodi',
users: 'Korisnici',
},
@@ -1091,20 +1091,13 @@ export default {
},
sections: {
content: 'Obsah',
forms: 'Formuláře',
library: 'Knihovna',
media: 'Média',
member: 'Členové',
packages: 'Balíčky',
settings: 'Nastavení',
translation: 'Překlad',
users: 'Uživatelé',
concierge: 'Domovník',
courier: 'Kurýr',
developer: 'Vývojář',
installer: 'Průvodce nastavením Umbraca',
newsletters: 'Zpravodaje',
statistics: 'Statistiky',
help: 'Nápověda',
},
settings: {
defaulttemplate: 'Výchozí šablona',
@@ -1288,14 +1288,14 @@ export default {
},
sections: {
content: 'Cynnwys',
forms: 'Ffurflenni',
library: 'Llyfrgell',
media: 'Cyfrwng',
member: 'Aelodau',
packages: 'Pecynnau',
marketplace: 'Marchnad',
settings: 'Gosodiadau',
translation: 'Cyfieithiad',
users: 'Defnyddwyr',
marketplace: 'Marchnad',
},
settings: {
defaulttemplate: 'Templed diofyn',
@@ -1474,6 +1474,7 @@ export default {
},
sections: {
content: 'Indhold',
library: 'Bibliotek',
media: 'Mediearkiv',
member: 'Medlemmer',
packages: 'Pakker',
@@ -1056,7 +1056,7 @@ export default {
greeting5: 'Willkommen',
greeting6: 'Willkommen',
instruction: 'Hier anmelden:',
signInWith: 'Anmelden mit',
signInWith: 'Anmelden mit {0}',
timeout: 'Sitzung abgelaufen',
forgottenPassword: 'Kennwort vergessen?',
forgottenPasswordInstruction:
@@ -1309,7 +1309,7 @@ export default {
},
sections: {
content: 'Inhalte',
forms: 'Formulare',
library: 'Bibliothek',
media: 'Medien',
member: 'Mitglieder',
packages: 'Pakete',
@@ -885,20 +885,14 @@ export default {
editscript: 'Editar fichero de script',
},
sections: {
concierge: 'Conserje',
content: 'Contenido',
courier: 'Mensajero',
developer: 'Desarrollador',
installer: 'Asistente de configuración de Umbraco',
library: 'Biblioteca',
media: 'Media',
member: 'Miembros',
newsletters: 'Boletín informativo',
packages: 'Paquetes',
settings: 'Ajustes',
statistics: 'Estadísticas',
translation: 'Traducción',
users: 'Usuarios',
help: 'Ayuda',
packages: 'Paquetes',
},
settings: {
defaulttemplate: 'Plantilla por defecto',
@@ -923,7 +923,7 @@ export default {
greeting5: 'Bienvenue',
greeting6: 'Bienvenue',
instruction: 'Connectez-vous ci-dessous',
signInWith: 'Identifiez-vous avec',
signInWith: 'Identifiez-vous avec {0}',
timeout: 'La session a expiré',
forgottenPassword: 'Mot de passe oublié?',
forgottenPasswordInstruction:
@@ -1117,7 +1117,7 @@ export default {
},
sections: {
content: 'Contenu',
forms: 'Formulaires',
library: 'Bibliothèque',
media: 'Medias',
member: 'Membres',
packages: 'Packages',
@@ -548,16 +548,11 @@ export default {
editscript: 'ערוך קובץ סקריפט',
},
sections: {
concierge: 'Concierge',
content: 'תוכן',
courier: 'Courier',
developer: 'מפתח',
installer: 'אשף הגדרת אומברקו',
library: 'ספרייה',
media: 'מדיה',
member: 'חברים',
newsletters: 'עיתון',
settings: 'הגדרות',
statistics: 'סטטיסטיקות',
translation: 'תירגום',
users: 'משתמשים',
},
@@ -1228,20 +1228,13 @@ export default {
editscript: 'Uredite datoteku skripte',
},
sections: {
concierge: 'Portirnica',
content: 'Sadržaj',
courier: 'Kurir',
developer: 'Developer',
forms: 'Forme',
help: 'Pomoć',
installer: 'Umbraco Konfiguracijski Čarobnjak',
library: 'Knjižnica',
media: 'Mediji',
member: 'Članovi',
newsletters: 'Newsletteri',
packages: 'Paketi',
marketplace: 'Marketplace',
settings: 'Postavke',
statistics: 'Statistika',
translation: 'Prijevodi',
users: 'Korisnici',
},
@@ -1232,7 +1232,7 @@ export default {
},
sections: {
content: 'Contenuto',
forms: 'Forms',
library: 'Biblioteca',
media: 'Media',
member: 'Membri',
packages: 'Pacchetti',
@@ -719,20 +719,13 @@ export default {
editscript: 'スクリプトファイルの編集',
},
sections: {
concierge: '管理人',
content: 'コンテンツ',
courier: 'Courier',
developer: '開発',
installer: 'Umbraco 設定ウィザード',
library: 'ライブラリ',
media: 'メディア',
member: 'メンバー',
newsletters: 'ニュースレター',
settings: '設定',
statistics: '統計',
translation: '翻訳',
users: 'ユーザー',
help: 'ヘルプ',
forms: 'フォーム',
},
settings: {
defaulttemplate: '既定のテンプレート',
@@ -546,16 +546,11 @@ export default {
editscript: '스크립트 파일 편집',
},
sections: {
concierge: '안내',
content: '컨텐츠',
courier: '가이드',
developer: '개발도구',
installer: 'Umbraco 설치마법사',
library: '라이브러리',
media: '미디어',
member: '구성원',
newsletters: '뉴스레터',
settings: '세팅',
statistics: '통계',
translation: '변환',
users: '사용자',
},
@@ -712,7 +712,7 @@ export default {
greeting5: 'Velkommen',
greeting6: 'Velkommen',
instruction: 'Logg på nedenfor',
signInWith: 'Logg på med',
signInWith: 'Logg på med {0}',
timeout: 'Din sesjon er utløpt',
continue: 'Fortsett',
validate: 'Valider',
@@ -871,20 +871,13 @@ export default {
editscript: 'Rediger scriptfilen',
},
sections: {
concierge: 'Concierge',
content: 'Innhold',
courier: 'Courier',
developer: 'Utvikler',
installer: 'Umbraco konfigurasjonsveiviser',
library: 'Bibliotek',
media: 'Mediaarkiv',
member: 'Medlemmer',
newsletters: 'Nyhetsbrev',
settings: 'Innstillinger',
statistics: 'Statistikk',
translation: 'Oversettelse',
users: 'Brukere',
help: 'Hjelp',
forms: 'Skjemaer',
},
settings: {
defaulttemplate: 'Standardmal',
@@ -943,7 +943,7 @@ export default {
greeting5: 'Welkom',
greeting6: 'Welkom',
instruction: 'log hieronder in',
signInWith: 'Inloggen met',
signInWith: 'Inloggen met {0}',
timeout: 'Sessie is verlopen',
forgottenPassword: 'Wachtwoord vergeten?',
forgottenPasswordInstruction:
@@ -1187,7 +1187,7 @@ export default {
},
sections: {
content: 'Inhoud',
forms: 'Formulieren',
library: 'Bibliotheek',
media: 'Media',
member: 'Leden',
packages: 'Packages',
@@ -845,20 +845,13 @@ export default {
editscript: 'Edytuj skrypt',
},
sections: {
concierge: 'Concierge',
content: 'Treść',
courier: 'Kurier',
developer: 'Deweloper',
installer: 'Konfigurator Umbraco',
library: 'Biblioteka',
media: 'Media',
member: 'Członkowie',
newsletters: 'Biuletyny',
settings: 'Ustawienia',
statistics: 'Statystyki',
translation: 'Tłumaczenie',
users: 'Użytkownicy',
help: 'Pomoc',
forms: 'Formularze',
},
settings: {
defaulttemplate: 'Domyślny szablon',
@@ -375,14 +375,8 @@ export default {
editscript: 'Editar arquivo de script',
},
sections: {
concierge: 'Porteiro',
courier: 'Mensageiro',
developer: 'Desenvolvedor',
installer: 'Assistente de Configuração Umbraco',
media: 'Mídia',
newsletters: 'Boletins Informativos',
settings: 'Configurações',
statistics: 'Estatísticas',
users: 'Usuários',
},
settings: {
@@ -1403,6 +1403,7 @@ export default {
},
sections: {
content: 'Conteúdo',
library: 'Biblioteca',
media: 'Multimédia',
member: 'Membros',
packages: 'Pacotes',
@@ -12,7 +12,7 @@ import type { UmbLocalizationDictionary } from '@umbraco-cms/backoffice/localiza
export default {
sections: {
content: 'Conţinut',
forms: 'Formulare',
library: 'Bibliotecă',
media: 'Media',
member: 'Membrii',
packages: 'Pachete',
@@ -1023,18 +1023,11 @@ export default {
editscript: 'Править файл скрипта',
},
sections: {
concierge: 'Смотритель',
content: 'Содержимое',
courier: 'Курьер',
developer: 'Разработка',
forms: 'Формы',
help: 'Помощь',
installer: 'Мастер конфигурирования Umbraco',
library: 'Библиотека',
media: 'Медиа-материалы',
member: 'Участники',
newsletters: 'Рассылки',
settings: 'Установки',
statistics: 'Статистика',
translation: 'Перевод',
users: 'Пользователи',
},
@@ -694,7 +694,7 @@ export default {
greeting5: 'Välkommen',
greeting6: 'Välkommen',
instruction: 'Logga in nedan',
signInWith: 'Logga in med',
signInWith: 'Logga in med {0}',
timeout: 'Sessionen har nått sin maxgräns',
continue: 'Fortsätt',
validate: 'Validera',
@@ -883,19 +883,12 @@ export default {
editscript: 'Redigera script',
},
sections: {
concierge: 'Concierge',
content: 'Innehåll',
courier: 'Courier',
developer: 'Utvecklare',
forms: 'Formulär',
help: 'Hjälp',
installer: 'Umbraco konfigurationsguide',
library: 'Bibliotek',
media: 'Media',
member: 'Medlemmar',
newsletters: 'Nyhetsbrev',
packages: 'Paket',
settings: 'Inställningar',
statistics: 'Statistik',
translation: 'Översättning',
users: 'Användare',
},
@@ -1094,19 +1094,12 @@ export default {
editscript: 'Komut dosyasını düzenle',
},
sections: {
concierge: 'Konsiyerj',
content: 'İçerik',
courier: 'Kurye',
developer: 'Geliştirici',
forms: 'Formlar',
help: 'Yardım',
installer: 'Umbraco Yapılandırma Sihirbazı',
library: 'Kitaplık',
media: 'Medya',
member: 'Üyeler',
newsletters: 'Bültenler',
packages: 'Paketler',
settings: 'Ayarlar',
statistics: 'İstatistikler',
translation: 'Çeviri',
users: 'Kullanıcılar',
},
@@ -1021,18 +1021,11 @@ export default {
editscript: 'Редагувати файл скрипта',
},
sections: {
concierge: 'Консьєрж',
content: 'Вміст',
courier: "Кур'єр",
developer: 'Розробка',
forms: 'Форми',
help: 'Допомога',
installer: 'Майстер конфігурування Umbraco',
library: 'Бібліотека',
media: 'Медіа-матеріали',
member: 'Учасники',
newsletters: 'Розсилки',
settings: 'Налаштування',
statistics: 'Статистика',
translation: 'Переклад',
users: 'Користувачі',
},
@@ -1413,6 +1413,7 @@ export default {
},
sections: {
content: 'Nội dung',
library: 'Thư viện',
media: 'Phương tiện',
member: 'Thành viên',
packages: 'Gói mở rộng',
@@ -691,20 +691,13 @@ export default {
editscript: '編輯腳本',
},
sections: {
concierge: 'Concierge',
content: '內容',
courier: 'Courier',
developer: '開發',
installer: '設定精靈',
library: '資源庫',
media: '媒體',
member: '會員',
newsletters: '消息',
settings: '設置',
statistics: '統計',
translation: '翻譯',
users: '用戶',
help: '說明',
forms: '表單',
},
settings: {
defaulttemplate: '預設範本',
@@ -689,20 +689,13 @@ export default {
editscript: '编辑脚本',
},
sections: {
concierge: '礼宾',
content: '内容',
courier: '导游',
developer: '开发',
installer: 'Umbraco配置向导',
library: '资源库',
media: '媒体',
member: '会员',
newsletters: '消息',
settings: '设置',
statistics: '统计',
translation: '翻译',
users: '用户',
help: '帮助',
forms: '窗体',
},
settings: {
defaulttemplate: '默认模板',
+2 -2
View File
@@ -6,6 +6,6 @@
"build": "vite build"
},
"dependencies": {
"@umbraco-ui/uui": "^2.0.0-rc.1"
"@umbraco-ui/uui": "^2.0.0"
}
}
}
@@ -0,0 +1,134 @@
import type { ManifestBase } from '../types/index.js';
import { UmbExtensionRegistry } from '../registry/extension.registry.js';
import { loadManifestPlainJs } from '../functions/load-manifest-plain-js.function.js';
import { UmbExtensionInitializerBase } from './extension-initializer-base.js';
import { UmbObserver } from '../../observable-api/observer.js';
import { expect, fixture } from '@open-wc/testing';
import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
@customElement('umb-test-initializer-base-host')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class UmbTestInitializerBaseHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
async function wait(ms: number) {
await new Promise((r) => setTimeout(r, ms));
}
// Factory for a concrete initializer over the 'test' manifest type. The base constructor's
// `observe` callback fires synchronously during `super()` — before any subclass field would
// initialise — so the record of instantiated aliases is a closed-over array created up front
// rather than instance state.
function createTestInitializer(host: UmbControllerHostElement, registry: UmbExtensionRegistry<ManifestBase>) {
const instantiated: string[] = [];
class UmbTestInitializer extends UmbExtensionInitializerBase<'test'> {
constructor() {
super(host, registry as never, 'test');
}
async instantiateExtension(manifest: ManifestBase & { js?: unknown }): Promise<void> {
if (manifest.js) {
await loadManifestPlainJs(manifest.js as never);
}
instantiated.push(manifest.alias);
}
unloadExtension(manifest: ManifestBase): void {
const index = instantiated.indexOf(manifest.alias);
if (index !== -1) instantiated.splice(index, 1);
}
}
return { initializer: new UmbTestInitializer(), instantiated };
}
describe('UmbExtensionInitializerBase — loaded signal', () => {
let hostElement: UmbControllerHostElement;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-initializer-base-host></umb-test-initializer-base-host>`);
});
// Regression for the v17.4+ external-login race (introduced in #22522).
//
// A default Umbraco install registers ZERO app-entry-point extensions. The boot sequence
// awaits the app-entry-point initializer's `loaded` before deciding which login provider
// to use. If `loaded` never resolves when there are no matching extensions, that await
// hangs forever — which is precisely why the await was removed, leaving externally
// registered auth providers un-awaited and the login flow racing on slow connections.
//
// So: an initializer for a type with zero matching extensions MUST still resolve `loaded`.
it('resolves `loaded` even when no extensions of the type are registered', async () => {
const extensionRegistry = new UmbExtensionRegistry<ManifestBase>();
const { initializer } = createTestInitializer(hostElement, extensionRegistry);
const outcome = await Promise.race([
new UmbObserver(initializer.loaded).asPromise().then(() => 'resolved'),
wait(1000).then(() => 'timeout'),
]);
expect(outcome, '`loaded` must resolve for an initializer with zero matching extensions').to.equal('resolved');
});
// Regression for the late-loading race that the external-login bug is built on.
//
// This simulates an extension that registers AFTER the initial load and whose
// instantiation is slow (the app-entry-point case: its onInit registers an auth provider
// after an async module load). A consumer that awaits `loaded` must not be told "loaded"
// until that late, slow extension has actually finished instantiating — otherwise it makes
// its decision (e.g. which login provider to redirect to) against a stale registry.
it('does not report `loaded` until a late-registered, slow extension has finished instantiating', async () => {
const extensionRegistry = new UmbExtensionRegistry<ManifestBase>();
// Initial, fast extension — load settles to `true`.
extensionRegistry.register({ type: 'test', name: 'a', alias: 'Umb.Test.A' } as never);
const { initializer, instantiated } = createTestInitializer(hostElement, extensionRegistry);
await new UmbObserver(initializer.loaded).asPromise();
expect(instantiated, 'initial extension instantiated').to.eql(['Umb.Test.A']);
// A late, slow extension registers (mirrors an app-entry-point's onInit registering an
// auth provider after an async delay).
extensionRegistry.register({
type: 'test',
name: 'b-late',
alias: 'Umb.Test.B.Late',
js: () => new Promise((r) => setTimeout(() => r({}), 100)),
} as never);
// Awaiting `loaded` now must wait for the late extension to finish instantiating.
const lateExtInstantiatedWhenLoaded = await new UmbObserver(initializer.loaded)
.asPromise()
.then(() => instantiated.includes('Umb.Test.B.Late'));
expect(
lateExtInstantiatedWhenLoaded,
'`loaded` resolved before the late, slow extension finished instantiating',
).to.be.true;
});
// Permission-timing guard (re: the #22522 "user permissions resolved too late" concern).
//
// The backoffice route is gated by `#loadedGuard`, which awaits `bundleInitializer.loaded`
// via `.asPromise()`; the private extensions and user-permission data that load behind that
// gate must not be raced. So the gate must NOT open until the extensions registered before it
// was awaited have actually finished instantiating. This guards against a naive "resolve
// unconditionally" that sets `loaded` before instantiation completes.
//
// Note: user-permission *condition* resolution itself lives in UmbBaseExtensionInitializer
// (see base-extension-initializer.race.test.ts) — a different class this change does not touch.
it('does not open the `loaded` gate until the initially-registered extensions have instantiated', async () => {
const extensionRegistry = new UmbExtensionRegistry<ManifestBase>();
extensionRegistry.register({
type: 'test',
name: 'slow-boot',
alias: 'Umb.Test.SlowBoot',
js: () => new Promise((r) => setTimeout(() => r({}), 100)),
} as never);
const {initializer, instantiated} = createTestInitializer(hostElement, extensionRegistry);
const instantiatedWhenGateOpened = await new UmbObserver(initializer.loaded)
.asPromise()
.then(() => instantiated.includes('Umb.Test.SlowBoot'));
expect(instantiatedWhenGateOpened, '`loaded` opened the gate before the extension instantiated').to.be.true;
});
});
@@ -20,11 +20,23 @@ export abstract class UmbExtensionInitializerBase<
#loaded = new UmbBooleanState(undefined);
loaded = this.#loaded.asObservable();
// Identifies the current processing pass. The observer callback is async, so passes can
// overlap; only the latest pass is allowed to settle `loaded`, so a slower earlier pass
// cannot unblock waiters before the newest set of extensions has finished instantiating.
#loadPass = 0;
constructor(host: UmbElement, extensionRegistry: UmbExtensionRegistry<T>, manifestType: Key) {
super(host);
this.host = host;
this.extensionRegistry = extensionRegistry;
this.observe(extensionRegistry.byType<Key, T>(manifestType), async (extensions) => {
const pass = ++this.#loadPass;
// Re-arm while this pass is in flight so a consumer awaiting `loaded` waits for it to
// finish instead of resolving on a stale `true` from a previous pass. `undefined`
// rather than `false` because `asPromise()` resolves on the first non-undefined value.
this.#loaded.setValue(undefined);
this.#extensionMap.forEach((existingExt) => {
if (!extensions.find((b) => b.alias === existingExt.alias)) {
this.unloadExtension(existingExt);
@@ -32,7 +44,10 @@ export abstract class UmbExtensionInitializerBase<
}
});
await Promise.all(
// `allSettled` so a throwing/rejecting `instantiateExtension` cannot leave `loaded`
// stuck at `undefined` and hang a waiter (e.g. the app boot gate). Failures are
// surfaced rather than swallowed.
const results = await Promise.allSettled(
extensions.map((extension) => {
if (this.#extensionMap.has(extension.alias)) return;
this.#extensionMap.set(extension.alias, extension);
@@ -40,7 +55,16 @@ export abstract class UmbExtensionInitializerBase<
}),
);
if (extensions.length > 0) {
for (const result of results) {
if (result.status === 'rejected') {
console.error('[UmbExtensionInitializer] Failed to instantiate extension', result.reason);
}
}
// Only the latest pass settles `loaded`. Resolving unconditionally — including for
// zero extensions — so a consumer awaiting `loaded` (the app-entry-point boot gate,
// the bundle guard) never hangs on a default install that registers none of this type.
if (pass === this.#loadPass) {
this.#loaded.setValue(true);
}
});
@@ -317,6 +317,7 @@ export class UmbContentTypeDesignEditorTabElement extends UmbLitElement {
uui-box.opaque {
background-color: transparent;
border-color: transparent;
--uui-box-default-padding: 0;
}
.container-list {
@@ -98,8 +98,7 @@ export class UmbCollectionPaginationElement extends UmbLitElement {
}
uui-pagination {
display: block;
margin-top: var(--uui-size-layout-1);
margin-top: var(--uui-size-space-3);
}
`,
];
@@ -107,7 +107,6 @@ export class UmbInputMultipleTextStringItemElement extends UUIFormControlWithBas
<uui-button
compact
label="${this.localize.term('general_remove')} ${this.value}"
look="outline"
?disabled=${this.disabled}
@click=${this.#onDelete}>
<uui-icon name="icon-trash"></uui-icon>
@@ -128,6 +127,7 @@ export class UmbInputMultipleTextStringItemElement extends UUIFormControlWithBas
#validation-message {
flex: 1;
margin-bottom: calc(var(--uui-size-1) * -1);
}
#input {
@@ -136,11 +136,28 @@ export class UmbInputMultipleTextStringItemElement extends UUIFormControlWithBas
.handle {
cursor: grab;
opacity: 0.6;
transition: opacity 120ms;
}
.handle:active {
cursor: grabbing;
}
uui-button {
opacity: 0;
transition: opacity 120ms;
}
:host(:hover),
:host(:focus-within) {
uui-button {
opacity: 1;
}
.handle {
opacity: 1;
}
}
`,
];
}
@@ -172,8 +172,7 @@ export class UmbPickerSearchResultElement extends UmbLitElement {
}
uui-pagination {
display: block;
margin-top: var(--uui-size-layout-1);
margin-top: var(--uui-size-space-3);
}
`,
];
@@ -307,10 +307,6 @@ export class UmbDashboardRedirectManagementElement extends UmbLitElement {
}
}
uui-pagination {
display: inline-block;
}
.pagination {
display: flex;
justify-content: center;
@@ -199,9 +199,6 @@ export class UmbDocumentHistoryWorkspaceInfoAppElement extends UmbLitElement {
}
uui-pagination {
flex: 1;
display: flex;
justify-content: center;
margin-top: var(--uui-size-layout-1);
}
`,
@@ -148,7 +148,6 @@ export class UmbLogViewerMessagesListElement extends UmbLitElement {
static override styles = [
css`
uui-pagination {
display: block;
margin-bottom: var(--uui-size-layout-1);
}
uui-box {
@@ -183,9 +183,6 @@ export class UmbMediaHistoryWorkspaceInfoAppElement extends UmbLitElement {
}
uui-pagination {
flex: 1;
display: flex;
justify-content: center;
margin-top: var(--uui-size-layout-1);
}
`,
@@ -738,7 +738,6 @@ export class UmbMediaPickerModalElement extends UmbPickerModalBaseElement<
}
uui-pagination {
display: block;
margin-top: var(--uui-size-layout-1);
}
@@ -119,7 +119,6 @@ export class UmbMemberGroupPickerModalElement extends UmbModalBaseElement<
static override styles = [
css`
uui-pagination {
display: block;
margin-top: var(--uui-size-layout-1);
}
`,
@@ -147,9 +147,6 @@ export class UmbPackagesCreatedOverviewElement extends UmbLitElement {
display: flex;
justify-content: space-around;
}
uui-pagination {
display: inline-block;
}
.container {
display: flex;
@@ -224,7 +224,6 @@ export class UmbRelationTypeDetailWorkspaceViewElement extends UmbLitElement imp
uui-pagination {
margin-top: var(--uui-size-layout-1);
display: block;
}
`,
];
@@ -167,11 +167,6 @@ export class UmbEntityReferencesWorkspaceInfoAppElement extends UmbLitElement {
justify-content: center;
margin-top: var(--uui-size-space-4);
}
uui-pagination {
flex: 1;
display: inline-block;
}
`,
];
}
@@ -116,8 +116,6 @@ export class UmbCurrentUserHistoryUserProfileAppElement extends UmbLitElement {
}
uui-pagination {
display: flex;
justify-content: center;
margin-top: var(--uui-size-layout-1);
}
`,
@@ -34,7 +34,7 @@
"cases": [
{
"condition": "(StarterKit == 'Umbraco.TheStarterKit' && (UmbracoRelease == 'Latest' || UmbracoRelease == 'Custom'))",
"value": "17.0.0"
"value": "18.0.0"
},
{
"condition": "(StarterKit == 'Umbraco.TheStarterKit' && UmbracoRelease == 'LTS')",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@umbraco-cms/acceptance-test-helpers",
"version": "18.0.0-rc1",
"version": "18.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@umbraco-cms/acceptance-test-helpers",
"version": "18.0.0-rc1",
"version": "18.0.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -1,6 +1,6 @@
{
"name": "@umbraco-cms/acceptance-test-helpers",
"version": "18.0.0-rc1",
"version": "18.0.0",
"description": "Test helpers and builders for making Playwright tests for Umbraco solutions",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -95,6 +95,15 @@ export default defineConfig({
ignoreHTTPSErrors: true,
}
},
// Unauthenticated: this exercises the login screen itself (a late-registered auth provider).
{
name: 'authProviderLateRegistration',
testMatch: 'AuthProviderLateRegistration/**/*.spec.ts',
use: {
...devices['Desktop Chrome'],
ignoreHTTPSErrors: true,
}
},
// This project is used to test the install steps, for that we do not need to authenticate.
{
name: 'unattendedInstallConfig',
@@ -0,0 +1,30 @@
// Test fixture: an appEntryPoint that registers an external auth provider LATE — i.e. after
// an async delay inside onInit — mirroring a real provider (e.g. Umbraco ID) whose onInit
// registers its authProvider after fetching/initialising on a slow connection.
//
// The backoffice boot must wait for app-entry-points to settle before deciding which login
// provider to use. If it doesn't, the login screen renders before this provider is registered
// and the late provider never appears (the v17.4+ regression). The delay makes that race
// deterministic.
const LATE_REGISTRATION_DELAY_MS = 1500;
export const onInit = async (_host, extensionRegistry) => {
await new Promise((resolve) => setTimeout(resolve, LATE_REGISTRATION_DELAY_MS));
extensionRegistry.register({
type: 'authProvider',
alias: 'Test.LateAuthProvider',
name: 'Late External Login',
forProviderName: 'Umbraco.LateTest',
meta: {
label: 'Late External Login',
defaultView: {
icon: 'icon-cloud',
},
behavior: {
autoRedirect: false,
},
},
});
};
@@ -0,0 +1,12 @@
{
"name": "Late Auth Provider (test)",
"allowPublicAccess": true,
"extensions": [
{
"type": "appEntryPoint",
"alias": "Test.LateAuthProvider.EntryPoint",
"name": "Late Auth Provider Entry Point",
"js": "/App_Plugins/LateAuthProvider/entry-point.js"
}
]
}
@@ -0,0 +1,30 @@
import {test} from '@umbraco/acceptance-test-helpers';
import {expect} from '@playwright/test';
// Regression guard for the v17.4+ external-login race (introduced in #22522).
//
// AdditionalSetup/App_Plugins/LateAuthProvider deploys an appEntryPoint whose onInit, after a
// 1.5s delay, registers an external auth provider ("Late External Login"). This mirrors a real
// provider (e.g. Umbraco ID) that registers its authProvider during an async onInit on a slow
// connection.
//
// The backoffice boot must wait for app-entry-points to settle before rendering the login
// screen. If it does not, the login decision is made before the provider is registered, the
// late provider never appears, and the user is dropped on the local login instead. So: the
// late-registered provider MUST be offered on the login screen.
//
// This test is intentionally brittle (it depends on boot timing) but must remain working — it
// is the only end-to-end guard for the boot-gate behaviour.
test('a late-registered external auth provider is offered on the login screen', async ({umbracoUi}) => {
test.slow();
// Act - navigate to the backoffice unauthenticated (the login screen).
await umbracoUi.goToBackOffice();
// Assert - the provider registered late by the appEntryPoint is still offered. On the
// buggy boot the login screen renders before this provider exists, so it never appears.
const lateProviderButton = umbracoUi.page
.locator('umb-auth-provider-default')
.getByText('Sign in with Late External Login');
await expect(lateProviderButton).toBeVisible({timeout: 15000});
});
@@ -9,8 +9,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoFixture.AutoMoq" />
<PackageReference Include="AutoFixture.NUnit3" />
<PackageReference Include="Moq" />
<PackageReference Include="NUnit" />
</ItemGroup>
@@ -96,7 +96,7 @@ public class ChildrenElementTreeControllerTests : ManagementApiUserGroupTestBase
.WithAlias(Guid.NewGuid().ToString("N"))
.WithName("Test Group With Element Start Node")
.WithAllowedSections(["library"])
.WithPermissions(new HashSet<string> { ActionElementBrowse.ActionLetter })
.WithPermissions(new HashSet<string> { ActionElementBrowse.ActionLetter, ActionElementContainerBrowse.ActionLetter })
.WithStartElementId(startNodeFolder.Id)
.Build();
@@ -65,7 +65,7 @@ public class RootElementTreeControllerTests : ManagementApiUserGroupTestBase<Roo
.WithAlias(Guid.NewGuid().ToString("N"))
.WithName("Test Group With Element Start Node")
.WithAllowedSections(["library"])
.WithPermissions(new HashSet<string> { ActionElementBrowse.ActionLetter })
.WithPermissions(new HashSet<string> { ActionElementBrowse.ActionLetter, ActionElementContainerBrowse.ActionLetter })
.WithStartElementId(folder1.Id)
.Build();
@@ -64,7 +64,7 @@ public class SiblingsElementTreeControllerTests : ManagementApiUserGroupTestBase
.WithAlias(Guid.NewGuid().ToString("N"))
.WithName("Test Group With Element Start Node")
.WithAllowedSections(["library"])
.WithPermissions(new HashSet<string> { ActionElementBrowse.ActionLetter })
.WithPermissions(new HashSet<string> { ActionElementBrowse.ActionLetter, ActionElementContainerBrowse.ActionLetter })
.WithStartElementId(_folder1Id)
.Build();
@@ -1,8 +1,6 @@
using System.Linq.Expressions;
using System.Net;
using System.Net.Http.Json;
using Umbraco.Cms.Api.Management.Controllers.TemporaryFile;
using Umbraco.Cms.Api.Management.ViewModels.TemporaryFile;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.TemporaryFile;
@@ -42,8 +40,11 @@ public class CreateTemporaryFileControllerTests : ManagementApiUserGroupTestBase
protected override async Task<HttpResponseMessage> ClientRequest()
{
CreateTemporaryFileRequestModel createTemporaryFileRequest = new() { Id = Guid.NewGuid(), File = null! };
// The endpoint only consumes multipart/form-data, so the request must be sent as such to reach the action.
// The required file is intentionally omitted, yielding the expected BadRequest for authenticated users.
using var content = new MultipartFormDataContent();
content.Add(new StringContent(Guid.NewGuid().ToString()), "Id");
return await Client.PostAsync(Url, JsonContent.Create(createTemporaryFileRequest));
return await Client.PostAsync(Url, content);
}
}
@@ -1278,7 +1278,8 @@
"type": "null"
}
]
}
},
"plainJson": { }
}
},
"ArticlePageContentResponseModel": {
@@ -170,6 +170,9 @@ internal abstract class OpenApiContractTestBase : OpenApiTestBase
// discriminator mapping refs that match the registered schema names.
var polymorphicDataType = await CreatePolymorphicTestDataTypeAsync();
// Create a Plain JSON data type to exercise the unconstrained-type schema path (#23034).
var plainJsonDataType = await CreatePlainJsonDataTypeAsync();
// Create a composition type that exposes shared SEO metadata properties
var seoMetadataComposition = new ContentTypeBuilder()
.WithAlias("seoMetadata")
@@ -223,6 +226,11 @@ internal abstract class OpenApiContractTestBase : OpenApiTestBase
.WithName("Polymorphic Test")
.WithDataTypeId(polymorphicDataType.Id)
.Done()
.AddPropertyType()
.WithAlias("plainJson")
.WithName("Plain JSON")
.WithDataTypeId(plainJsonDataType.Id)
.Done()
.Done()
.Build();
articlePage.AddContentType(seoMetadataComposition);
@@ -343,4 +351,22 @@ internal abstract class OpenApiContractTestBase : OpenApiTestBase
await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey);
return dataType;
}
private async Task<IDataType> CreatePlainJsonDataTypeAsync()
{
var editor = PropertyEditorCollection[Constants.PropertyEditors.Aliases.PlainJson]
?? throw new InvalidOperationException(
$"Property editor '{Constants.PropertyEditors.Aliases.PlainJson}' was not registered.");
var dataType = new DataType(editor, ConfigurationEditorJsonSerializer)
{
Name = "Plain JSON",
DatabaseType = ValueStorageType.Ntext,
ParentId = Constants.System.Root,
CreateDate = DateTime.UtcNow,
};
await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey);
return dataType;
}
}
@@ -0,0 +1,114 @@
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Cache;
/// <summary>
/// Tests for <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/>.
/// </summary>
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, WithApplication = true)]
internal sealed class ElementContainerDeletedDistributedCacheNotificationHandlerTests : UmbracoIntegrationTest
{
private IElementContainerService ElementContainerService => GetRequiredService<IElementContainerService>();
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
private IElementService ElementService => GetRequiredService<IElementService>();
private IEntityService EntityService => GetRequiredService<IEntityService>();
private static readonly UmbracoObjectTypes[] _treeObjectTypes =
[UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element];
protected override void CustomTestSetup(IUmbracoBuilder builder)
{
// Integration tests use a no-op server messenger and do not register the distributed cache
// notification handlers by default, so opt in to the element handlers under test and a messenger
// that delivers cache refreshes locally.
builder.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>();
builder.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>();
builder.Services.AddUnique<IServerMessenger, ContentEventsTests.LocalServerMessenger>();
}
/// <summary>
/// Regression test for https://github.com/umbraco/Umbraco-CMS/issues/23072: the element tree's children
/// query resolves the container key to an id via <see cref="IIdKeyMap"/>. When a container is deleted the
/// handler must evict its mapping, otherwise a container recreated under the same key resolves to the old
/// (now non-existent) id and nested elements stay invisible in the tree until the application is restarted.
/// </summary>
[Test]
public async Task Can_Resolve_Children_After_Container_Recreated_Under_Same_Key()
{
IContentType elementType = await CreateElementTypeAsync();
var containerKey = Guid.NewGuid();
// Create the container and resolve its children once, so its key->id mapping is cached in IdKeyMap.
EntityContainer firstContainer = await CreateContainerAsync(containerKey, "Container v1");
Attempt<int> warmResolve = IdKeyMap.GetIdForKey(containerKey, UmbracoObjectTypes.ElementContainer);
Assert.IsTrue(warmResolve.Success, "Expected IdKeyMap to resolve the newly created container key.");
Assert.AreEqual(firstContainer.Id, warmResolve.Result);
// Delete and recreate under the same key - the recreated container gets a new id.
Attempt<EntityContainer?, EntityContainerOperationStatus> deleteResult =
await ElementContainerService.DeleteAsync(containerKey, Constants.Security.SuperUserKey);
Assert.IsTrue(deleteResult.Success, $"Failed to delete container: {deleteResult.Status}");
EntityContainer secondContainer = await CreateContainerAsync(containerKey, "Container v2");
Assert.AreNotEqual(firstContainer.Id, secondContainer.Id, "Recreated container should have a new id.");
IElement element = CreateElementUnder(secondContainer.Id, elementType);
// Without the fix, the stale containerKey->firstContainer.Id mapping survives and the children query
// resolves to the old (now non-existent) parent id, returning nothing.
Attempt<int> resolvedAfter = IdKeyMap.GetIdForKey(containerKey, UmbracoObjectTypes.ElementContainer);
Assert.IsTrue(resolvedAfter.Success, "Expected IdKeyMap to resolve the recreated container key.");
Assert.AreEqual(secondContainer.Id, resolvedAfter.Result, "Container key should resolve to the recreated container id.");
AssertChildrenContains(containerKey, element.Key);
}
private void AssertChildrenContains(Guid containerKey, Guid expectedElementKey)
{
IEntitySlim[] children = EntityService
.GetPagedChildren(containerKey, _treeObjectTypes, _treeObjectTypes, 0, 100, false, out var total)
.ToArray();
Assert.AreEqual(1, total, "Expected the element tree children query to return the nested element.");
Assert.IsTrue(children.Any(child => child.Key == expectedElementKey), "Nested element was not returned by the children query.");
}
private async Task<IContentType> CreateElementTypeAsync()
{
IContentType elementType = ContentTypeBuilder.CreateSimpleElementType();
await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey);
return elementType;
}
private async Task<EntityContainer> CreateContainerAsync(Guid key, string name)
{
Attempt<EntityContainer?, EntityContainerOperationStatus> result =
await ElementContainerService.CreateAsync(key, name, null, Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success, $"Failed to create container: {result.Status}");
return result.Result!;
}
private IElement CreateElementUnder(int parentId, IContentType elementType)
{
var element = new Element($"Element {Guid.NewGuid():N}", parentId, elementType);
OperationResult saveResult = ElementService.Save(element);
Assert.IsTrue(saveResult.Success, "Failed to save element.");
return element;
}
}

Some files were not shown because too many files have changed in this diff Show More