Report 31 -- DropDown.Flexible Migration Analysis (DEFINITIVE)¶
Date: 2026-02-28 Priority: P0 -- Critical (crashes on dropdown reads) Week Plan Task: M1 (Marco) Status: ROOT CAUSE CONFIRMED from Umbraco v17 source code
Problem Statement¶
The migration tool produces dropdown values that crash Umbraco v17's FlexibleDropdownPropertyValueConverter. The root cause is a serialization mismatch: the migration tool embeds a native JSON array inside block JSON, but Umbraco v17 expects a JSON string (double-encoded array) at that position.
CORRECTION: V8 Source Format¶
Previous report was WRONG. V8 does NOT store dropdowns as plain strings.
V8 stores dropdown values as JSON arrays in umbracoPropertyData.varcharValue:
varcharValue = ["teal-blue-horizontal"] -- single-select
varcharValue = ["value1","value2"] -- multi-select
This was confirmed by direct SQL query against the v8 database (progresscu.ie). The Umbraco.DropDown.Flexible property editor in v8 serializes selected values as a JSON array string, regardless of whether multiple is true or false.
V17 Expected Format (from Umbraco Source)¶
Umbraco v17's FlexibleDropdownPropertyValueConverter (in Umbraco.Cms.Core) expects:
For direct properties (in umbracoPropertyData.textValue):¶
A JSON string of an array:
When the property converter reads this, it calls JsonSerializer.Deserialize<string[]>(inter.ToString()) which correctly parses the JSON string into a string[].
For properties inside BlockGrid/BlockList JSON:¶
A JSON string value (double-encoded) inside the block JSON object:
{
"contentData": [{
"values": [{
"alias": "imageLocation",
"value": "[\"Left\"]" <-- STRING value containing JSON array
}]
}]
}
The value field must be a string that happens to contain a JSON array. When Umbraco's block deserializer reads this, it treats it as an inter value and passes it to the property value converter, which then deserializes the string into string[].
The Bug: Native JSON Array vs JSON String¶
What the migration tool produces (WRONG):¶
GridValueTypeConverter.ConvertDropdownValue() at line ~658 returns a JArray object:
var normalizedValues = new JArray();
foreach (var val in extractedValues) {
normalizedValues.Add(normalized);
}
return normalizedValues; // Returns JArray OBJECT
When this JArray is embedded into the block JSON by GridContentMigrationService, it serializes as a native JSON array:
{
"contentData": [{
"values": [{
"alias": "imageLocation",
"value": ["Left"] <-- NATIVE array, NOT a string
}]
}]
}
What Umbraco v17 expects (CORRECT):¶
{
"contentData": [{
"values": [{
"alias": "imageLocation",
"value": "[\"Left\"]" <-- STRING containing JSON array
}]
}]
}
Why this crashes:¶
When FlexibleDropdownPropertyValueConverter receives the native array ["Left"], it tries to call .ToString() on it (which returns [\n "Left"\n] with formatting), then tries to JsonSerializer.Deserialize<string[]>() on that -- which may or may not work depending on the exact serialization path. In many code paths, the type mismatch causes a JsonException or returns unexpected values.
Double-encoding bug in database:¶
There is also evidence of a double-encoding bug where previously-migrated data has been re-processed, producing values like:
This is a JSON array containing a string that itself contains a JSON array -- the result of the migration tool wrapping an already-wrapped value.
All Code Paths That Touch Dropdown Values¶
13 Methods Across 6 Classes¶
| # | Class | Method | What It Does | Bug? |
|---|---|---|---|---|
| 1 | GridValueTypeConverter |
ConvertValue() |
Dispatcher -- routes to ConvertDropdownValue() for dropdown editors |
Entry point |
| 2 | GridValueTypeConverter |
ConvertDropdownValue() |
Parses v8 value, normalizes, returns JArray |
YES -- returns JArray instead of string |
| 3 | GridValueTypeConverter |
ExtractDropdownValues() |
Extracts values from various formats (string, array, object) | OK |
| 4 | GridValueTypeConverter |
NormalizeDropdownValue() |
Trims, lowercases, removes brackets | OK |
| 5 | DropDownConverter |
ConvertAsync() |
Registry converter -- unwraps single arrays to plain string | Correct but overridden |
| 6 | DropDownConverter |
ParseDropdownArray() |
Parses JSON array from string | OK |
| 7 | SqlMigrationService |
TransformDropdownPropertyValue() |
Direct property migration -- wraps string as ["value"] |
CORRECT (returns string) |
| 8 | SqlMigrationService |
TransformDataType() |
Routes to TransformDropdownPropertyValue() for dropdown data types |
OK |
| 9 | GridContentMigrationService |
MigrateGridContentAsync() |
Calls GridValueTypeConverter.ConvertValue() for each property |
Embeds returned value |
| 10 | GridContentMigrationService |
SetPropertyValue() |
Sets the value on the block JSON object | Serializes JArray as native |
| 11 | GridConfigurationMigrationService |
CreateDropdownDataType() |
Creates the v17 dropdown data type config | OK (config, not values) |
| 12 | BlockGridExtensions |
SafeDropdownValue() |
V17 view helper -- extracts string from JSON array | Defense layer |
| 13 | CalculatorService |
GetDropdownValue() |
V17 calculator helper -- same as SafeDropdownValue | Defense layer |
The Fix¶
Root Cause Fix (Migration Tool)¶
File: GridValueTypeConverter.cs -- ConvertDropdownValue() (line ~658)
Change: Return a JSON string instead of a JArray object:
// BEFORE (buggy):
return normalizedValues; // JArray object -- serializes as native array
// AFTER (fixed):
return normalizedValues.ToString(Formatting.None); // String -- serializes as "["value"]"
This single-line change ensures the value is embedded as a string inside the block JSON, which is what Umbraco v17's property value converter expects.
Direct Properties Path (Already Correct)¶
File: SqlMigrationService.cs -- TransformDropdownPropertyValue() (line ~5812)
This path is already correct. It returns a (string result, ...) tuple, which gets written to textValue in the database. The value ["Left"] stored as a database string is exactly what v17 expects for direct properties.
V17 View Defense (Keep)¶
File: BlockGridExtensions.cs -- SafeDropdownValue() (line ~261)
This helper should be kept as a defense layer for already-migrated data that contains the native array format. It handles both formats:
public static string SafeDropdownValue(this IPublishedElement content, string alias, string fallback = "")
{
try
{
var raw = content.Value<string>(alias);
if (string.IsNullOrWhiteSpace(raw)) return fallback;
// Try parse as JSON array
if (raw.TrimStart().StartsWith("["))
{
var arr = JArray.Parse(raw);
return arr.Count > 0 ? arr[0]?.ToString() ?? fallback : fallback;
}
return raw;
}
catch
{
return fallback;
}
}
All Affected Dropdown Properties¶
| Property Alias | Used In | Description |
|---|---|---|
gradient |
ctaCurves, ctaText, heroItem, heroElement, splitPictureSlider, testimonialCardItem, headerNew, headerNews, services_list_image | Background gradient class |
buttonStyle |
ctaCurves, ctaText, headerNew, splitPictureSlider, NewsGlobalCTA | CSS button class |
imageLocation |
cardItem, cardGridControls, heroItem, heroElement, testimonialCardItem, _CardText, _CardItemContent | Image position |
flip |
flipCardControls, faqControls, faqElement | Flip card animation |
display |
announcementControls, loanboxescompletegrideditor, footerListControl | Layout display mode |
layout |
spotlightGridControls, cardGridControls | Layout variant |
layoutStyle |
spotlightControls | Layout style |
height |
standardPageWow, standardPageWithImageText, googleMap, largegooglemap, largeleafletmap | Component height |
buttonClass |
buttonWidgetControl, accordionButtonElement, accordionButtonMain, mainLoanBoxWidget, iconListOptContactInfoControl | Button CSS class |
barChartType |
barChart, barChartItem | Chart type |
cols |
flipCardControls | Column count |
iconPosition |
announcementItemScrollControls, announcementControls | Icon placement |
zoomLevel |
googleMap, largeleafletmap, blocklist/googleMap | Map zoom |
class |
ctaText, parallaxImageItem, faqItems, gridslider, headerNew | CSS class |
defaultLoanCalcFrequency |
calculatorControls | Calculator frequency |
alignTitle |
careerWidgetControl | Title alignment |
listStyle |
footerLinks | List styling |
dropDownListFirstOption |
releaseNotesWidget | Dropdown label |
Protection Status¶
Protected (26 locations) -- using SafeDropdownValue() or try/catch¶
| File | Property | Method |
|---|---|---|
announcementItemScrollControls.cshtml:20 |
display | SafeDropdownValue() |
calculatorControls.cshtml:35 |
defaultLoanCalcFrequency | SafeDropdownValue() |
careerWidgetControl.cshtml:28 |
alignTitle | SafeDropdownValue() |
ctaText.cshtml:21,23 |
buttonStyle, gradient | SafeDropdownValue() |
footerListControl.cshtml:13 |
display | SafeDropdownValue() |
splitPictureSlider.cshtml:31,32 |
gradient, buttonStyle | SafeDropdownValue() |
spotlightGridControls.cshtml:17 |
layout | SafeDropdownValue() |
heroItem.cshtml:24,26 |
imageLocation, gradient | SafeDropdownValue() |
heroElement.cshtml:23,25 |
imageLocation, gradient | SafeDropdownValue() |
spotlightControls.cshtml:19 |
layoutStyle | SafeDropdownValue() |
barChart.cshtml:20 |
barChartType | try/catch |
faqControls.cshtml:27 |
flip | try/catch |
faqElement.cshtml:15 |
flip | try/catch |
cardItem.cshtml:28 |
imageLocation | try/catch |
_CardText.cshtml:15 |
imageLocation | try/catch |
_CardItemContent.cshtml:15 |
imageLocation | try/catch |
testimonialCardItem.cshtml:25,33 |
imageLocation, gradient | try/catch |
buttonWidgetControl.cshtml:15 |
class | try/catch |
accordionButtonElement.cshtml:31,68 |
buttonClass | try/catch |
accordionButtonMain.cshtml:35,78 |
buttonClass | try/catch |
mainLoanBoxWidget.cshtml:159 |
buttonClass | try/catch |
standardPageWow.cshtml:31 |
height | try/catch |
blocklist/headerNew.cshtml:45,50 |
gradient, buttonStyle | try/catch |
blocklist/headerNews.cshtml:35 |
gradient | try/catch |
blocklist/googleMap.cshtml:27,30 |
zoomLevel, height | try/catch |
NewsGlobalCTA.cshtml:53,64 |
buttonStyle, gradient | try/catch |
grid_element_largeleafletmap.cshtml:30,33 |
zoomLevel, height | try/catch |
UNPROTECTED (18+ locations) -- WILL CRASH until migration tool fix is applied¶
| File | Property | Risk |
|---|---|---|
announcementControls.cshtml:13 |
display | CRASH |
announcementControls.cshtml:64 |
iconPosition | CRASH |
ctaCurves.cshtml:33,35 |
gradient | CRASH |
ctaCurves.cshtml:68,70 |
buttonStyle | CRASH |
flipCardControls.cshtml:18,20 |
flip | CRASH |
flipCardControls.cshtml:23,25 |
cols | CRASH |
googleMap.cshtml:23 |
zoomLevel | CRASH |
googleMap.cshtml:24 |
height | CRASH |
grid_element_loanboxescompletegrideditor.cshtml:17 |
display | CRASH |
grid_element_largegooglemap.cshtml:77 |
height | CRASH |
grid_element_services_list_image.cshtml:69 |
gradient | CRASH |
barChartItem.cshtml:23 |
barChartType | CRASH |
cardGridControls.cshtml:37,41,45 |
imageLocation, styling, contentAlignment | CRASH |
iconListOptContactInfoControl.cshtml:92,197 |
buttonClass | CRASH |
parallaxImageItem.cshtml:74,76 |
class | CRASH |
standardPageWithImageText.cshtml:26,27 |
height, buttonClass | CRASH |
releaseNotesWidget.cshtml:8,20 |
dropDownListFirstOption | CRASH |
footerLinks.cshtml:14 |
listStyle | CRASH |
faqItems.cshtml:18 |
class | CRASH |
Key Files¶
Migration Tool (Root Cause)¶
| File | Lines | Role | Fix Needed? |
|---|---|---|---|
GridValueTypeConverter.cs |
530-658 | ConvertDropdownValue() returns JArray -- should return string |
YES -- line ~658 |
SqlMigrationService.cs |
5812-5852 | TransformDropdownPropertyValue() returns string |
Already correct |
DropDownConverter.cs |
36-127 | Registry converter -- correct but overridden by GridValueTypeConverter | No change needed |
GridContentMigrationService.cs |
489-494 | Calls GridValueTypeConverter and embeds result in JSON | No change needed |
V17 Defense Layer (Keep)¶
| File | Lines | Role |
|---|---|---|
BlockGridExtensions.cs |
261-277 | SafeDropdownValue() helper -- defense for already-migrated data |
CalculatorService.cs |
220-243 | GetDropdownValue() helper for calculators |
Fix Plan¶
Step 1: Migration Tool Fix (1 line)¶
File: GridValueTypeConverter.cs -- ConvertDropdownValue() (~line 658)
Step 2: Re-run Migration¶
After the fix, re-run the full migration to regenerate all block content with correctly-encoded dropdown values.
Step 3: Verify¶
Query the v17 database to confirm dropdown values inside block JSON are stored as strings:
Expected: "value": "[\"Left\"]" (string), NOT "value": ["Left"] (native array).
Step 4: Keep SafeDropdownValue() as Defense¶
Even after the fix, keep SafeDropdownValue() in views as a defense layer. It handles both formats gracefully and protects against:
- Already-migrated data that hasn't been re-migrated yet
- Any edge cases in the double-encoding bug
- Future-proofing against format changes
Week Plan Assignment¶
- M1 (Marco): Apply the one-line fix to
GridValueTypeConverter.ConvertDropdownValue()-- return.ToString(Formatting.None)instead of raw JArray - J3 (Joao): Add
SafeDropdownValue()to all 18+ unprotected views (defense layer) - M7 (Marco): Re-run migration after fix and verify dropdown values are correctly double-encoded strings