Skip to main content

The items corrector & the editor-load gotcha

Two related mechanisms live here: the items corrector, which turns a loose tree into a valid grid at render time, and the editor-load path, which is where the single most common extension bug comes from. They're documented together because the bug is a direct consequence of when each one runs.

The items corrector

_Page_Builder_Items_Corrector (in …/includes/items-corrector/) is run by json_to_shortcodes() before the shortcode string is generated. Its job is to guarantee a valid section → row → column → element grid no matter how loosely the user (or an importer) arranged things.

What it does

  • Wraps loose simple elements. A bare element with no surrounding column/row/section becomes section → row → column → element. At the root, consecutive loose elements are gathered into one auto-generated section (atts.auto_generated = true).
  • Groups columns into rows. Consecutive column items are accumulated into a row. In the default (Bootstrap 5) mode every column of a section stays in one .fw-row and CSS flex-wrap handles the visual wrapping; in legacy mode the corrector splits columns into multiple rows once their widths exceed 12/12. See Column widths.
  • Handles nested columns. A column whose _items contain child columns gets an inner row synthesized around them (correct_nested_columns()), recursing to any depth.
  • Handles the Container element. When a section contains a container item, the section's own loose rows are wrapped into a default .fw-container so user Container elements sit beside it as siblings (never nested), and the section view is flagged (has_inner_containers) to skip its own container wrapper.

When it runs (and when it doesn't)

Correction runs only when section, row and column item types are all registered and no explicit disable config is set (needs_correction()). It is skipped for single-item re-renders such as the Live Editor refreshing one column — that path runs only normalize_nested_columns() so nested columns still alias correctly. The whole decision is filterable via fw:ext:page-builder:json-structure-needs-correction.

The editor-load gotcha

Here is the trap that bites anyone who changes an existing option on an existing element.

get_value_from_attributes() does NOT run on normal editor load

Page_Builder_Simple_Item::get_value_from_attributes() is the PHP method that re-derives an element's option values (filling defaults, refreshing auto-generated values). It runs during:

  • shortcode → builder conversion (the items-corrector path), and
  • front-end render.

It does not run when you simply open an element's options modal in the builder. The modal is constructed straight from the raw saved atts:

// in each item's static/js/scripts.js
this.modal = new fw.OptionsModal({
options: this.initOptions.modalOptions,
values: this.model.get('atts'), // ← RAW saved atts, no PHP migration
});

Why that breaks pre-existing items

Suppose you convert an existing option from a scalar (say a select storing a string) to a multi-picker (which stores an array). For a newly added element everything is fine — the default value is already the new array shape. But a pre-existing element still has the old string saved. When its modal opens, that string reaches the multi-picker's PHP _render(), which does $value['preset'] on a string → illegal string offset → the options-render AJAX corrupts → the modal shows a blank "error:".

The tell-tale symptom: only old items error; newly-added ones are fine.

caution
The fix is JS-side, in the item's scripts.js

Because the modal renders raw atts, a PHP migration in get_value_from_attributes() alone does not fix the editor — you must migrate the value in the item's scripts.js before the modal opens, and this.model.set('atts', migrated) so a save persists the new shape. Keep the PHP migrator too (it covers the shortcode→builder and front-end paths). The canonical worked example is the Section's min_height migration — see Migrating an option's value shape.

The resilience guard

Even with migrations in place, a value-shape mismatch on the render path used to be catastrophic: one option throwing inside get_value_from_attributes() aborted the entire conversion, the item loaded with empty atts, and if the page was then saved, the user's real content was silently overwritten with nothing.

That re-derivation is now wrapped in a guard:

try {
$attributes['atts'] = fw_get_options_values_from_input( $options, array() );
} catch ( \Throwable $e ) {
// Fall back to the raw saved atts: a shape change can at worst mis-render
// a single field, never wipe the whole item.
unset( $e );
}
A safety net, not a substitute for migration

The guard prevents data loss, but a mis-shaped value still mis-renders until you migrate it. Changing an existing option's type is a breaking value-shape change — add the JS + PHP migrators and keep the consuming view.php tolerant of the legacy value, so the field renders correctly rather than merely surviving.