Problem/Motivation

TypedDataManager::getPropertyInstance() maintains a static prototype cache to avoid repeatedly constructing typed data objects for the same property path. The cache key is built from:

  1. The root definition's data type (e.g., map)
  2. The root definition's settings (JSON-encoded, if any)
  3. The object's property path
  4. The property name (for ComplexDataInterface)

This key does not incorporate any information about the property's own definition — not its data type, not its class, and not its constraints. As a result, when two MapDataDefinition instances define a property with the same name but different types or constraints, they produce identical cache keys and share a single prototype. Whichever instance accesses the property first determines the prototype for all subsequent accesses in that request.

This produces four failure modes for Maps and one for Lists:

  1. Exception: A Map property cached as a complex type (e.g., Map) is later expected to be a scalar type — Map::setValue() rejects the scalar with "Invalid values given. Values must be represented as an associative array."
  2. Silent wrong type: A Map property cached as one scalar type (e.g., StringData) is later expected to be a different scalar type (e.g., IntegerData) — no exception, but the wrong typed data class is returned.
  3. Constraints silently dropped: A Map property cached without constraints causes later Maps that define constraints on the same property to have those constraints ignored — validation passes when it should fail.
  4. Constraints silently leaked: A Map property cached with constraints causes later Maps that define no constraints on the same property to inherit them — validation fails when it should pass.
  5. List item constraints dropped/leaked: Two ListDataDefinition instances with the same item type but different item constraints share a prototype — the first list's constraint state wins.

Steps to reproduce

Each variant below is a self-contained reproduction. Run via drush php:eval or a kernel test.

Variant 1 — Exception: Map property type collision

use Drupal\Core\TypedData\MapDataDefinition;
use Drupal\Core\TypedData\DataDefinition;

$tdm = \Drupal::typedDataManager();

// Map A: 'title' is a nested Map.
$map_a = MapDataDefinition::create('map');
$map_a->setPropertyDefinition('title', MapDataDefinition::create('map'));
$a = $tdm->create($map_a, ['title' => []]);
$a->get('title'); // Caches prototype "map::title" => Map

// Map B: 'title' is a string.
$map_b = MapDataDefinition::create('map');
$map_b->setPropertyDefinition('title', DataDefinition::create('string'));
$b = $tdm->create($map_b, ['title' => 'Hello']);
$b->get('title');
// => InvalidArgumentException: "Invalid values given. Values must be
//    represented as an associative array."

Variant 2 — Silent wrong type: scalar type collision

$tdm = \Drupal::typedDataManager();

// Map A: 'count' is a string.
$map_a = MapDataDefinition::create('map');
$map_a->setPropertyDefinition('count', DataDefinition::create('string'));
$a = $tdm->create($map_a, ['count' => 'five']);
$a->get('count'); // Caches "map::count" => StringData

// Map B: 'count' is an integer.
$map_b = MapDataDefinition::create('map');
$map_b->setPropertyDefinition('count', DataDefinition::create('integer'));
$b = $tdm->create($map_b, ['count' => 42]);
$prop = $b->get('count');

get_class($prop); // Drupal\Core\TypedData\Plugin\DataType\StringData
// Expected: IntegerData. The wrong prototype was returned silently.

Variant 3 — Constraints dropped

$tdm = \Drupal::typedDataManager();

// Map A: 'email' is an unconstrained string.
$map_a = MapDataDefinition::create('map');
$map_a->setPropertyDefinition('email', DataDefinition::create('string'));
$a = $tdm->create($map_a, ['email' => 'not-an-email']);
$a->get('email'); // Caches prototype without constraints

// Map B: 'email' is a string with an Email constraint.
$map_b = MapDataDefinition::create('map');
$email_def = DataDefinition::create('string');
$email_def->addConstraint('Email');
$map_b->setPropertyDefinition('email', $email_def);
$b = $tdm->create($map_b, ['email' => 'not-an-email']);
$violations = $b->get('email')->validate();

// Expected: 1 violation. Actual: 0.
// The Email constraint is silently ignored.

Variant 4 — Constraints leaked

$tdm = \Drupal::typedDataManager();

// Map C: 'name' has a Length(max: 5) constraint.
$map_c = MapDataDefinition::create('map');
$name_def = DataDefinition::create('string');
$name_def->addConstraint('Length', ['max' => 5]);
$map_c->setPropertyDefinition('name', $name_def);
$c = $tdm->create($map_c, ['name' => 'Hi']);
$c->get('name'); // Caches prototype WITH Length constraint

// Map D: 'name' is an unconstrained string.
$map_d = MapDataDefinition::create('map');
$map_d->setPropertyDefinition('name', DataDefinition::create('string'));
$d = $tdm->create($map_d, ['name' => 'A perfectly valid long name']);
$violations = $d->get('name')->validate();

// Expected: 0 violations. Actual: 1 ("This value is too long").
// The Length constraint leaked from Map C.

Variant 5 — List item constraints dropped

use Drupal\Core\TypedData\ListDataDefinition;

$tdm = \Drupal::typedDataManager();

// List A: unconstrained string items.
$list_a = ListDataDefinition::create('string');
$la = $tdm->create($list_a, ['anything']);
foreach ($la as $item) { break; } // Caches item prototype without constraints

// List B: string items with Length(max: 3).
$item_def = DataDefinition::create('string');
$item_def->addConstraint('Length', ['max' => 3]);
$list_b = new ListDataDefinition([], $item_def);
$lb = $tdm->create($list_b, ['this is way too long']);
foreach ($lb as $item) {
  $violations = $item->validate();
}

// Expected: 1 violation. Actual: 0.
// The constraint was lost because the cached prototype has none.

Scope of impact

  • Maps — different property types: Affected (variants 1–2). Exception or silent wrong type.
  • Maps — same property type, different constraints: Affected (variants 3–4). Constraints lost or leaked.
  • Maps — same property type and constraints: Not affected. Correct behavior (coincidental cache hit).
  • Maps with different root types (e.g., field_item:link:{settings} vs standalone map): Not affected. The root type differentiates the cache key.
  • Lists — different item types: Not affected. The root type includes the item type (e.g., list:string vs list:integer).
  • Lists — same item type, different constraints: Affected (variant 5). Constraints lost or leaked.

Property names commonly involved in collisions: title, value, format, status, options, uri, description.

Root cause

The cache key in TypedDataManager::getPropertyInstance() (line ~170 of core/lib/Drupal/Core/TypedData/TypedDataManager.php) is:

$parts[] = $root_definition->getDataType();
if ($settings = $root_definition->getSettings()) {
  $parts[] = json_encode($settings);
}
$parts[] = $object->getPropertyPath();
if ($object instanceof ComplexDataInterface) {
  $parts[] = $property_name;
}
$key = implode(':', $parts);
// Produces e.g.: "map::title"

For ComplexDataInterface, the key assumes all Maps with the same root type, path, and property name have identical property definitions. This assumption is valid for entity field items (which have specific root types like field_item:link:{settings}), but invalid for standalone MapDataDefinition instances where different Maps routinely define the same property names with different types and constraints.

For ListInterface, the key assumes all lists with the same root type have identical item definitions. This is valid when item types differ, but not when item constraints differ.

Proposed resolution

Disable prototype caching for ComplexDataInterface property lookups.

The caching model assumes positional interchangeability: "all properties at this path are the same." This assumption holds for Lists (all items share one item definition) but does not hold for Maps (each property has its own independent definition, and different Map instances can define the same property name arbitrarily differently).

For the Map case, the fix is to bypass the prototype cache entirely and construct a fresh typed data instance each time:

// In TypedDataManager::getPropertyInstance():

if ($object instanceof ComplexDataInterface) {
  $definition = $object->getDataDefinition()->getPropertyDefinition($property_name);
  if (!$definition) {
    throw new \InvalidArgumentException("Property $property_name is unknown.");
  }
  $property = $this->create($definition, NULL, $property_name, $object);
  if (isset($value)) {
    $property->setValue($value, FALSE);
  }
  return $property;
}

For the List constraint variant, the cache key should additionally incorporate the item definition's constraints, or the list case should also bypass the cache when item constraints are present.

Why not just add the data type to the key? That fixes variants 1–2 (type collisions) but not variants 3–5 (constraint collisions), since those involve the same data type. Including constraints in the key is impractical because constraint arrays can contain objects, closures, and deeply nested structures that are expensive to hash and may not be stably serializable.

Performance considerations: The prototype cache was introduced as an optimization for entity field access, where the same field type appears thousands of times at the same path. Entity field items use specific root types (e.g., field_item:link:{"title":1,"link_type":17}) that already produce unique cache keys and do not collide with standalone Maps. Removing the cache for the ComplexDataInterface path should therefore not impact entity performance. Benchmarking should confirm impact.

Remaining tasks

  • Write a kernel test covering all five variants
  • Implement the fix in TypedDataManager::getPropertyInstance() for both ComplexDataInterface (Maps) and ListInterface (Lists)

User interface changes

None.

Introduced terminology

None.

API changes

None. The prototype cache is an internal optimization detail of TypedDataManager. The public API contract is unchanged — getPropertyInstance() will now correctly return a typed data object matching the property's actual definition rather than a potentially stale cached prototype.

Data model changes

None.

Release notes snippet

Fixed a bug in TypedDataManager::getPropertyInstance() where its internal prototype cache could return typed data objects with the wrong type or constraints. This occurred when multiple MapDataDefinition or ListDataDefinition instances in the same request defined properties or items with the same name but different data types or constraints. Symptoms included InvalidArgumentException during Map property access, silently wrong return types, skipped validation constraints, and spurious validation failures.

Disclosure: AI was used to assist in writing this issue and creating tests.

Issue fork drupal-3616328

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

michaellander created an issue. See original summary.

michaellander’s picture

Title: TypedDataManager prototype cache returns wrong type/constraints for Map properties with identical names but different definitions » TypedDataManager prototype cache returns wrong type/constraints for Map/List properties with identical names but different definitions

michaellander’s picture

Issue summary: View changes
michaellander’s picture

Issue summary: View changes