Main menu

23 Oct 2024

Key collisions in nested objects

We identified incorrect behaviour in the handling of duplicate keys when exported to nested structures (like JSON objects). Adopting the new behaviour potentially alters your expected file structures, so this has been migrated carefully along with some new options.

Legacy handling

If your project is affected, you'll see a legacy mode switch in your project settings (Developer tab) as shown below:

img

  • Switching this off will apply corrected behaviour to all your future exports.
  • Leaving this on will keep your exported files unchanged, but you may find their output is wrong.

We recommend reviewing key collisions in your project, and switching this off once you've either fixed your keys, or are happy with the new behaviour. For examples of potential errors, see the detailed case study below.

New Export API parameters

A new collisions parameter has been added to the Export API. It's available now for migration purposes, and will be documented fully in due course. If you export nested structures to JSON, YAML or PHP, we recommend specifying collisions=ignore, (overwrite or fail) and reviewing your colliding keys.

  • Specifying collisions=legacy is equivalent to leaving your project settings in legacy mode, as shown above.
  • Specifying collisions=auto is equivalent to switching legacy mode off.
  • Omitting the collisions parameter will observe the project settings as either "legacy" (on) or "auto" (off).

See the examples below for more detail on what each of these options looks like.

Are you affected?

There are only two scenarios where key collisions can occur:

  1. Translations are indexed by non-unique source text. See our guide to duplicate key collisions.
  2. Translations are indexed uniquely, but key expansion causes overlaps. See our guide to nested object collisions.

Collisions on duplicate source texts

If you export duplicate source text keys to a generic JSON object with index=text you'll get a key collision. This is expected, but the handling of conflicts is changing.

The previous incorrect behaviour splits the conflicts into an array to avoid an invalid hash table. This has been deprecated because the output cannot be imported back into Loco without unwanted side effects. It also creates a nonsense entry that your application probably won't be expecting.

Various corrected behaviours can be used by specifying collisions, as either:

  • fail - throws a fatal error on the first collision during export. This is useful for debugging.
  • ignore - suppresses duplicates such at the first value encountered during export is used.
  • overwrite - allows each duplicate to override the previous (in file order).

Or - if you rely on the old behaviour and need to keep conflicting values unchanged, you can specify collisions=legacy.

Collisions on keys inside expanded objects

This is the most common cause of collisions that go unnoticed. Below is the same example as in our guide to nested object collisions:

select.fruit = "Select a fruit"
select.fruit.apple = "Apple"
select.fruit.banana = "Banana"

If you export this to a JSON object (or similar) with key expansion enabled, you'll get a collision on the key "fruit" which cannot be both a string AND an object in the same tree.

The old default behaviour adds the string value into the "fruit" object at "0". This is a bug, because the expanded object would import back into Loco as "select.fruit.0" and create an unwanted duplicate.

Below are detailed examples of how the values of the collisions parameter can mitigate this problem:


Legacy default: Deprecated

{
  "select" : {
    "fruit": {
      "0": "Select a fruit",
      "apple": "Apple",
      "banana": "Banana"
    }
  }
}

If you need this behaviour, you can use collisons=legacy. This will continue to work indefinitely, but beware that the resulting object should not be reimported. A better solution would be to explicitly use the ID "select.fruit.0" in Loco and in your application, and not rely on quirks.

Ignore strategy: collisions=ignore&order=id

{
  "select" : {
    "fruit": "Select a fruit"
  }
}

Overwrite strategy: collisions=overwrite&order=id

{
  "select" : {
    "fruit": {
      "apple": "Apple",
      "banana": "Banana"
    }
  }
}

These may both look like unwanted outcomes, but they are technically correct according to the rules of dot notation. Critically, reimporting either of these objects would be idempotent.

A better solution would be to review your keys and avoid the collision in both your application, and in Loco.

New default: Unspecified handling

{
  "select" : {
    "fruit": {
      "\\0": "Select a fruit",
      "apple": "Apple",
      "banana": "Banana"
    }
  }
}

This is the new default strategy for keeping both values, and applies when no collisions parameter has been specified and legacy mode has been disabled in your project settings.

The new approach uses a null byte (instead of a "0") to merge the string into the colliding object. The importer can safely ignore the null key and collapse the ID to just "select.fruit". Hence it remains idempotent.

Your application is unlikely to actually use this key, but it provides visibility as to where the keys need reviewing, and it's faithful to the fact that you haven't indicated a handling preference.

Last updated by