Skip to content

Fields with showIf dependencies silently not saved when dependency value can't be reconstructed server-side #2278

Description

@adrianbj

Short description of the issue

A field with a showIf dependency can be silently not saved when editing a page, because ProcessWire re-evaluates the showIf condition server-side during save and—in certain cases—cannot reconstruct the value the browser used to show/hide the field.

When you save a page, a showIf field is pulled out of the normal processing pass (InputfieldWrapper::isProcessable() queues it as a "delayed child") and is only saved if InputfieldForm::processInputShowIf() independently re-derives the condition as true, via selectorMatchesInputfield(). That method reads the dependency field's value to test the condition. This is a completely separate code path from the JavaScript that actually showed/hid the field in the editor, and when the two disagree the dependent field's input is discarded:

if(!$processNow) {
    $child->set('showIfSkipped', true);
    continue;            // processInput() is never called -> value dropped
}

There is no error; isChanged() stays false, so ProcessPageEdit (which only writes changed fields) never saves it. Removing the showIf rule "fixes" it, which is why this looks intermittent.

Expected behavior

A field that was visible (its showIf condition was met in the editor) and edited by the user is saved, regardless of how the dependency field is rendered server-side.

Actual behavior

The dependent field's value is silently discarded—no error, nothing saved—in the two cases below, even though the value is present in the submitted POST data.

The common cases are fine: plain text/checkbox/select dependencies, chained dependencies, and fields nested in fieldsets all re-evaluate correctly server-side. The failures are limited to two situations where the server cannot reconstruct the dependency value:

  1. Dependency field not present in the server-side form — it lives in an un-opened AJAX tab (the admin default for multi-tab editing), a conditionally-built form, or was removed. selectorMatchesInputfield() returns null (treated as "no match"), even though the submitted value is available.
  2. Dependency present but not processed — it is in a locked or collapsedHidden state, so it retains only its originally-loaded value. If a value was nevertheless submitted for it (e.g. a readonly/hidden mirror input), the server matches the stale loaded value instead of the submitted one.

Optional: Suggestion for a possible fix

Make InputfieldForm::selectorMatchesInputfield() fall back to the actually-submitted value (via $this->getInput()) in both situations, so the server evaluates the same value the browser used. Normally-processed and chained-showIf dependencies are untouched (they keep using their processed/sanitized values). showIf is a UX feature—field editability/access is enforced separately—so deferring to the submitted value here is safe.

// In selectorMatchesInputfield(), wire/modules/Inputfield/InputfieldForm.module

// (1) dependency field not present in the form: fall back to the submitted value
$inputfield = $this->getChildByName($name);
if(!$inputfield) {
    $input = $this->getInput();
    $postValue = $input ? $input->$name : null;
    if($postValue !== null) {
        if(is_array($postValue)) $postValue = implode('|', $postValue);
        return $selector->matches("$postValue");
    }
    if($name != 'collapsed') {
        $this->error("Warning ($debugNote): dependency field '$name' is not present in this form.", Notice::debug);
    }
    return null;
}

$value = $inputfield->attr('value');
$value2 = null;
$matches = false;

// (2) dependency present but in a non-processed (locked/hidden) state: prefer submitted value
static $notProcessedStates = array(
    Inputfield::collapsedHidden,
    Inputfield::collapsedLocked,
    Inputfield::collapsedNoLocked,
    Inputfield::collapsedBlankLocked,
    Inputfield::collapsedYesLocked,
    Inputfield::collapsedTabLocked,
);
if(in_array((int) $inputfield->getSetting('collapsed'), $notProcessedStates, true)) {
    $input = $this->getInput();
    $postValue = $input ? $input->$name : null;
    if($postValue !== null) {
        $value = is_array($postValue) && $subfield !== 'count' ? implode('|', $postValue) : $postValue;
    }
}

Verified on a real install with assertions covering both bug cases plus regressions (text/checkbox/select/chained/nested, multi-value matching, and the .count subfield).

Steps to reproduce the issue

  1. Create two fields, e.g. a Page-reference/select/checkbox field dep and a text field sif. Add both to a template.
  2. Give sif a visibility dependency: Show this field only if dep=<value>.
  3. Put dep on a separate tab in the template (so the admin loads it via AJAX), or set dep's visibility to Locked/Hidden.
  4. Edit a page: set dep to the value that makes sif visible, enter a value in sif, and save without opening the tab that contains dep (or with dep locked/hidden).
  5. Reload the page edit: sif is empty/unchanged—its value was silently dropped.

Setup/Environment

  • ProcessWire version: 3.0.263 (dev); present in current master and earlier versions
  • (Optional) PHP version: 8.5

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions