Skip to content

Builder: Never Save a Layout That Failed to Load - #1374

Open
Misplon wants to merge 16 commits into
fix/1366-resolve-prevent-crash-nullfrom
fix/1366-fail-closed-builder-save
Open

Builder: Never Save a Layout That Failed to Load#1374
Misplon wants to merge 16 commits into
fix/1366-resolve-prevent-crash-nullfrom
fix/1366-fail-closed-builder-save

Conversation

@Misplon

@Misplon Misplon commented Sep 5, 2026

Copy link
Copy Markdown
Member

Stacked on #1367. Refs #1366.

Reading order. The first two commits answer the review on #1367: sanitize_all() now removes a null or scalar style through one helper that uses array_key_exists() (the isset() blocks could never see a stored null), and the three save paths run that helper on the output of siteorigin_panels_data_pre_save, since sanitize_all() runs before that filter and the Toggle Visibility migration writes its null there. Then the ten fail-closed commits from this PR's first version, unchanged apart from one context line. Then four small follow-ups: a test-stub compatibility line, and three decoder tightenings so a submitted layout the builder could not load (numeric-keyed objects for lists, cells or widgets pointing past the rows, a row that is not an array) is refused before it replaces stored data.

What the fail-closed commits do. When a stored layout throws while the builder loads it, the builder no longer serializes the rows that did load. It records the failure, hands out the stored layout instead of the partial one, locks its controls behind a notice with the error and a Reload button, and disables the hidden panels_data field, so Update sends no layout and save_post() leaves the stored one untouched while title and content save normally. The Live Editor, the in-dialog Save, the nested Layout Builder widget and the Layout Block are closed off the same way. save_post(), save_home_page() and the Layout Builder widget refuse a submission that is not a layout instead of decoding it to an empty one and deleting what is stored. Nothing changes for a layout that loads cleanly.

Why. Reproduced on develop through the classic editor: a page with 3 widgets and 2 rows, one row style stored as null, saved as 0 widgets and 1 row after a single Update. With this stack the same Update leaves the stored layout byte-identical, and the null can no longer be written in the first place.

Merge path. Merge #1367 first, as-is; GitHub retargets this PR at develop; merge this next. The first two commits here are what make #1367's cleanup effective, so the two should ship in the same release. @AlexGStapleton if you'd rather carry those two commits inside #1367, cherry-pick bbd27ce3 and 14960a26; nothing on your branch was touched.

Tests. Node harness for the builder model (nvm use 20 && npm ci && npm test, 14 tests), PHP: 90 + 6 + 28 + 58 + 3 across the five suites, including real save_post() and save_home_page() driven with a null-writing pre-save callback. Verified in the browser on the classic editor, the nested widget, the Layout Block, an unparseable field, the admin-bar Live Editor link, Revert to Editor and the inline Save, and on the live site through the real save_post() with a Premium-shaped null-writing filter.

…r can add them

A style that is not an array cannot be sanitized or rendered, and a null
one crashes the builder's style checks when the layout loads. The removal
now lives in one helper, remove_invalid_styles(), which sanitize_all()
runs on the output of the data migration filter; the per-list checks it
replaces used isset(), which is false for a key holding null, so the null
case they were written for never reached them.

The three save paths also run the helper on the output of the
siteorigin_panels_data_pre_save filter. sanitize_all() runs before that
filter, so a callback on it could store a null style that nothing cleaned;
the Toggle Visibility migration in SiteOrigin Premium did exactly that.

Refs #1366, #1367.
sanitize_all() removes a null or scalar style on a widget, a row and a cell,
including one written by the data migration filter, and skips list items
that are not arrays; the removal helper's own contract is covered
separately. The real save_post() and save_home_page() are driven with a
siteorigin_panels_data_pre_save callback that writes null styles, and the
value handed to the meta write carries none of them; the ability write is
shown to hand the filter's output to the same cleanup.
loadPanelsData() stores a copy of the layout before it builds the live rows,
then swallows any exception thrown while building them. Everything that
serialized the builder afterwards read the live rows, so a layout that threw
part-way through, such as one with a null row or widget style, was written back
truncated on the next save, dropping every widget after the point of failure.

The model now records the failure. While it stands, refreshPanelsData() does
nothing and getPanelsData() returns the layout that was being loaded instead
of the incomplete rows, or null when the layout source could not be parsed.
The inline dialog save and the copy-content request skip a failed builder.
The flag clears only when a later load completes.

Refs #1366.
When the model reports a failed load, the builder view replaces its controls
with a notice carrying the error and a reload button, hides and inerts every
control present at that moment, and disables the hidden panels_data field.
A post save therefore carries no layout at all, and save_post() leaves the
stored one untouched instead of writing back whatever rows had loaded.

The classic editor no longer auto-switches to a locked builder, so the content
editor stays usable; the Page Builder tab still shows the locked builder but
never converts editor content into a layout while locked. The Live Editor,
the welcome message and the Layout Block's attribute write-back are held off
the same way. An unparseable data field is treated as a failed load rather
than an empty layout, which used to delete the stored one on the next save.

Refs #1366.
A small node:test setup loads the Backbone models the way main.js does, with
backbone and underscore from npm and stubs for the registry globals. The
first suite proves the fail-closed contract: a load that throws keeps the
stored layout in the data attribute, the serializer hands that layout out
instead of the partial rows, refreshes fire nothing, an unparseable source
hands out nothing, and a later successful load or Revert to Editor clears
the state.

The tests run on a current Node; the gulp 3 release build under build/ keeps
its own Node 10 toolchain, and the release copy excludes the new package
files alongside package.json.
… malformed

save_post() and save_home_page() indexed whatever json_decode() returned, so
malformed or scalar JSON became an empty layout and the stored one was
deleted; the home page handler had already written the post by then. Both
now decode through one helper that accepts only a real layout object or the
explicit empty layout that Revert to Editor submits, and return before any
write otherwise. The home page success message is shown only when a layout
was actually written.

The Layout Builder widget keeps its stored nested layout when the submission
carries no panels_data field, which is what a locked nested builder sends,
and when the field holds something that is not a layout.

Refs #1366.
The decoder's truth table: malformed JSON, scalars, lists and objects
without a layout list are refused; false and an empty object become the
empty layout that clears; a layout object passes unchanged, escaped quotes
and backslashes included. save_post() with a refused payload never reaches
the post, and save_home_page() writes nothing and reports no save, including
a refused save after a successful one on the same instance. The Layout
Builder widget keeps its stored nested layout when the field is absent or
refused, still takes a fresh builder id, and still sanitizes an array value.
The builder submits either a layout object or the literal false that Revert
to Editor sends. An empty object and an empty list decode to the same empty
PHP array, and neither is something the builder produces, so the decoder now
refuses both instead of turning them into a clear that deletes the stored
layout.
Switching to a locked builder hid the content editor and, with the builder's
own controls hidden by the lock, left no way back without a reload. The
switch now shows the lock notice beneath the content editor instead, both
on load and when the Page Builder tab is used. An admin-bar Live Editor
link opens the editor before the layout has loaded, so a failed load closes
it rather than trying to prevent it.
The builder always serializes grids and grid_cells, so an object carrying
only some of the layout lists is a partial layout rather than an empty one.
The decoder now refuses it instead of letting the save path read it as
empty and delete the stored layout; a missing widgets list is filled in.
The loader applies the same reading: an object with layout keys but no
grid cells is a failed load that locks the builder, while false and an
empty container remain the builder's own "no layout yet" values.
…lder's own empties

Only false and an empty container mean "no layout yet"; a scalar, a list
or an object without layout lists is not a layout, and building an empty
builder from it would let the next save replace the stored layout with an
empty one. Such values now take the failure path and lock the builder.

The JS test script names its files explicitly so it runs on every Node in
the declared range, and the decoder's docblock states the shape it accepts.
The loader now checks the same top-level shape the server accepts before
building rows, so a stored value with a non-list member locks the builder
instead of presenting an empty one that an edit could save over the stored
layout. The nested widget test now proves sanitize_all() runs on an array
layout and does not run on the preserving paths.
The save paths now call remove_invalid_styles() after the pre-save filter;
the stand-in for the styles admin in this suite passes the layout through
so the suite keeps observing what the save path itself does.
json_decode() turns a numeric-keyed JSON object into a PHP array as well,
but the builder indexes rows, cells and widgets by position, so such an
object is not a usable list even when its values are well formed. The
decoder now refuses a layout whose grids, grid_cells or widgets is not a
list with keys running from zero.
The builder addresses rows, cells and widgets by position, so a layout
whose cell or widget references do not resolve cannot be loaded; storing
it would only lock the builder on the next visit. The decoder now checks
that every cell names an existing row and every widget an existing row
and cell, reading the placement from panels_info or the older info key.
The loader reads each row's settings by position, so a row list holding a
null or a scalar cannot be loaded; the decoder now refuses it with the
other unresolvable shapes.
@Misplon
Misplon changed the base branch from develop to fix/1366-resolve-prevent-crash-null September 5, 2026 17:44
@Misplon
Misplon force-pushed the fix/1366-fail-closed-builder-save branch from 3e55270 to 0c0fef4 Compare September 5, 2026 17:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant