Skip to content Skip to sidebar

This topic contains 0 reply, has 1 voice.

Last updated by enrikB 3 months, 2 weeks ago.

Assisted by: Andreas W..

Author Posts
January 20, 2026 at 10:23 am #17745347

enrikB

## Summary

When using **ACF Options Pages** with **WPML ACF Multilingual (ACFML)** and setting fields to **“Copy once”** (`wpml_cf_preferences = 3`), the admin UI can incorrectly revert translated values to the default language on page reload.

Two related issues are observed:

1) **Boolean (`true_false`) cannot persist `0`** in secondary languages on admin reload (it snaps back to default `1`).
2) **WYSIWYG/text fields cannot remain intentionally empty** in secondary languages across subsequent saves; after clearing and saving again later (while field remains empty), the translated option row gets removed and ACFML falls back to default language on render.

Frontend output is correct (until a subsequent save, when the value from the default language gets saved); the issues are isolated to **admin options page rendering** and **save-time handling**.

## Environment / Context

- WordPress 6.9
- WPML Multilingual CMS 4.8.6
- Advanced Custom Fields Pro 6.7.0.2
- Advanced Custom Fields Multilingual 2.1.5

- ACF Options Page fields stored in `wp_options` as:
- `options_{field_name}` (default language)
- `options_{lang}_{field_name}` (secondary language)
- plus ACF reference rows:
- `_options_{...}_{field_name}` = `field_xxx`
- ACFML is active and handles options page translations via:
- `acf/pre_render_fields` filter (render-time adjustments)
- `acf/update_value` filter(s) (save-time adjustments)

## Expected Behavior

### For “Copy once” on options pages (general)
- If a translated value **does not exist yet**, the field should initially show (and/or copy) the default language value.
- If a translated value **exists**, the admin UI should display exactly that value, even if it is:
- `0` (false)
- `''` (empty string)
- `[]` (empty array)
- If a user explicitly clears a translated field, it should remain cleared after reload and on subsequent saves.

In other words: **“Copy once” should be “copy initially, then allow independent editing including clearing.”**

## Actual Behavior (Reproduction)

### Issue A — `true_false` cannot persist `0` in secondary language
1) Default language (DE): set boolean to `1`, save.
2) Secondary language (EN): set boolean to `0`, save.
3) After reload: the boolean shows `1` again (default language), even though frontend reads `0`.
4) Obviously after subsequent saving, the frontend will read 1 again (which is not expected and so confusing).

### Issue B — WYSIWYG/text cleared value reappears after reload (after later saves)
Using a WYSIWYG Copy-once field (example: `info_banner_content`):

#### Evidence from DB (`wp_options`, here table prefix `tcoc_`)
With value saved in FR:
- `_options_fr_info_banner_content` exists and equals `field_696a46ba5b8d0`
- `options_fr_info_banner_content` exists and contains `Francais`

After clearing content and saving (first time):
- `_options_fr_info_banner_content` still exists
- `options_fr_info_banner_content` exists but has **length 0** (`''`)

After modifying any other field later and saving again (content still empty):
- both rows disappear:
- `_options_fr_info_banner_content` removed
- `options_fr_info_banner_content` removed

Once removed, admin reload shows the default language value again (fallback), because the translated value is treated as “not set”.

## Root Cause Analysis (Plugin Code)

### Render-time fallback uses `empty()`, treating legitimate values as “not set”
File: acfml/classes/class-wpml-acf-options-page.php

```php
case WPML_COPY_ONCE_CUSTOM_FIELD:
if ( null === $field['value'] ) {
$field['value'] = $this->convert_relationship_field( acf_get_value( $post_id, $field ), $field );
}

if ( empty( $field['value'] ) && $this->is_field_on_translated_options_page( $post_id ) ) {
$field['value'] = $this->convert_relationship_field( acf_get_value( self::ORIGINAL_ID, $field ), $field );
}
break;
```

**Problem:** `empty()` returns true for valid, intentionally-set values like:
- `0`, `'0'`, `false`
- `''`
- `[]`

This causes ACFML to interpret “saved but empty/false” as “not set yet” and fallback to default language for display.

This directly explains Issue A (`true_false` value `0`) and contributes to Issue B when empty value persists.

### Save-time filter may convert empty string to `null` on subsequent saves, causing option deletion
File: acfml/classes/class-wpml-acf-custom-fields-sync.php

```php
public function clean_empty_values_for_copy_once_field( $value, $post_id, $field ) {
if ( '' === $value
&& ! $this->value_has_been_emptied( $field )
&& isset( $field['wpml_cf_preferences'] )
&& WPML_COPY_ONCE_CUSTOM_FIELD === $field['wpml_cf_preferences']
&& ! $this->isFieldType( $field, 'group' )
) {
$value = null;
}
return $value;
}
```

And:

```php
private function value_has_been_emptied( $field ) {
$state_before = $this->field_state->getStateBefore();
return ! empty( $state_before[ $field['name'] ] );
}
```

**Observed consequence:** On a later save when the field was already empty, acfml/classes/class-wpml-acf-custom-fields-sync.php:53:1-56:2 can return false (because `empty('')` is true), so `''` becomes `null`. For ACF options, `null` typically results in deletion of the option key. This matches the DB evidence where the `options_fr_info_banner_content` row disappears after a later save, re-triggering fallback.

## Proposed Fixes (Plugin-level)

## Fix 1 (Render): Replace `empty()` check with “has value / key exists” logic

**Goal:** Only fallback to default language when the translated value has truly never been set before (as in **copy once**), not when it’s a valid empty/false value (it's been already copied once, user has intentionally cleared it and wants it to be an empty value or 0).

### Suggested approach
- Determine whether the translated option key exists for options pages.
- Or use a stricter condition like `null === $field['value']` (not `empty()`).

### Minimal change (safer)
In get_field_options() acfml/classes/class-wpml-acf-options-page.php:95:1-127:2 for `WPML_COPY_ONCE_CUSTOM_FIELD`:

```diff
- if ( empty( $field['value'] ) && $this->is_field_on_translated_options_page( $post_id ) ) {
+ if ( null === $field['value'] && $this->is_field_on_translated_options_page( $post_id ) ) {
$field['value'] = $this->convert_relationship_field( acf_get_value( self::ORIGINAL_ID, $field ), $field );
}
```

**Pros:**
- Fixes boolean `0` immediately.
- Stops treating “cleared but saved as empty string” as unset.
- Low risk.

**Cons:**
- If ACF returns `''` for an unset field (it sometimes does), this may prevent first-time Copy once fallback. Better is key-existence detection.

### Better fix (options-page key existence)
Because options page storage is in `wp_options`, key existence can be checked reliably:

- Value option key: `{$post_id}_{$field['name']}`
- Reference key: `_{$post_id}_{$field['name']}`

If the key exists, do not fallback even if the value is empty.

Pseudo:

```php
$optionKey = $post_id . '_' . $field['name'];

$sentinel = '__acfml_notset__';
$hasTranslatedKey = get_option( $optionKey, $sentinel ) !== $sentinel;

if ( ! $hasTranslatedKey && $this->is_field_on_translated_options_page( $post_id ) ) {
$field['value'] = acf_get_value( self::ORIGINAL_ID, $field );
}
```

This yields correct Copy once semantics: “fallback only when not created yet”.

## Fix 2 (Save): Do not convert “intentionally cleared” translated values to `null` on options pages

**Goal:** Prevent deletion of `options_{lang}_{field}` and `_options_{lang}_{field}` rows when user cleared the field and later saves again.

The current clean_empty_values_for_copy_once_field acfml/classes/class-wpml-acf-custom-fields-sync.php:23:1-51:2 was designed for posts/postmeta (and early post creation). On options pages, deletion causes Copy once fallback loops.

### Proposed adjustment
In clean_empty_values_for_copy_once_field acfml/classes/class-wpml-acf-custom-fields-sync.php:23:1-51:2:

- If `$post_id` is an options page ID (string, e.g. `options_fr`) and the option key already exists, **do not set value to null**.

Pseudo:

```php
$isOptions = is_string($post_id) && ! is_numeric($post_id) && 0 === strpos($post_id, 'options_');

if ( $isOptions && '' === $value && WPML_COPY_ONCE_CUSTOM_FIELD === $field['wpml_cf_preferences'] ) {
$optionKey = $post_id . '_' . $field['name'];
$sentinel = '__acfml_notset__';
$exists = get_option( $optionKey, $sentinel ) !== $sentinel;

if ( $exists ) {
return ''; // preserve explicit empty
}
}
```

This keeps the empty translated value stable across subsequent saves and prevents accidental “unset” state.

## Fix 3 (Optional): Make value_has_been_emptied classes/class-wpml-acf-custom-fields-sync.php:53:1-56:2 robust against “already empty” history
Current:

```php
return ! empty( $state_before[ $field['name'] ] );
```

This treats previous empty as false, which is not aligned with “emptied intentionally” history.

If you want to track “user emptied it” as a state, do not rely on `empty()`. Use `array_key_exists`:

```php
return array_key_exists( $field['name'], $state_before );
```

(or track per-field state more explicitly). This is likely more invasive, so Fix 2 is the more localized change.

## Why are workarounds currently needed

Because plugin behavior currently:
- falls back on any `empty()` value at render time (breaking booleans and intentional empties),
- and may delete option keys after subsequent saves by converting `''` → `null`.

A plugin-level fix would remove the need for interception of ACF rendering and saving, which is currently needed for a logical "copy once" functioning.

The topic ‘[Closed] ACFML Bug Report: “Copy once” fields on Options Pages (admin reload issues with falsy/empty valu…’ is closed to new replies.