Skip to content

Add Bricks support on a shared builder-provider layer - #1287

Draft
danieliser wants to merge 3 commits into
fix/elementor-editor-supportfrom
fix/page-builder-compatibility
Draft

Add Bricks support on a shared builder-provider layer#1287
danieliser wants to merge 3 commits into
fix/elementor-editor-supportfrom
fix/page-builder-compatibility

Conversation

@danieliser

Copy link
Copy Markdown
Member

Issue

Popup Maker's page builder integrations each reimplemented the same lifecycle:
recognize a builder request, authorize it, restore the otherwise non-public popup
query, render an isolated canvas, and load assets for popups that are not the main
query post. #1271 expressed that lifecycle as a base controller plus traits, which
fit Elementor but encoded Elementor's mechanics as if they were universal.

This PR adds complete Bricks support and uses it to find the real common
denominator. Bricks was chosen as a deliberate architecture test.

What Bricks broke about the previous abstraction

Discovery is written up in docs/page-builder-discovery.md, with the runtime
evidence for each claim. The load-bearing differences:

Elementor Bricks
Ships as plugin theme (loads after plugins)
Post type gate supports popups already needs a site-owner setting opt-in
Document render get_builder_content_for_display() returns a string Frontend::render_data() returns markup via a different API; render_content() echoes and wraps in <main id="brx-content">
One-time bootstrap re-runnable Database::set_active_templates() self-gates; re-running is impossible and re-initializing by hand corrupts the host page
Late document CSS re-invoke Frontend::enqueue_styles() footer pass emits only global classes + AJAX data; per-element CSS must be generated and printed
Widget re-init one API call iterate window.bricksFunctions

The AssetBatching trait assumed "re-run the builder's asset handler." Bricks has
no such handler, so no shared trait can express both. What is shared is when to
flush and how to deduplicate — which belongs to a coordinator, not a trait.

Architecture

Modeled on Popup Maker's existing form provider architecture, as suggested:

BuilderProviders (registry)          Builders (coordinator)
├── Elementor provider               ├── preview authorization
├── Bricks provider                  ├── isolated canvas lifecycle
└── Divi provider                    └── secondary-document asset batching

Services
└── BuilderPreviewUrl (signed preview URLs)
Form providers Builder providers
PUM_Interface_Integration::enabled() BuilderProvider::is_available()
PUM_Integrations::$integrations BuilderProviders registry
pum_integrations filter popup_maker/register_builder_providers
get_enabled_form_integrations() BuilderProviders::available()

Intentionally not mirrored: form providers put optional behavior on the
abstract class as no-op methods. Builders differ far more, so optional behavior is
five capability interfaces. A provider that does not implement one is never asked.

Capability coverage

Capability Elementor Bricks Divi
EditsPopups
SupportsPopupPostType — already supports popups
RendersDocuments — renders via the_content
LoadsDocumentAssets
ProvidesPreviewUrl — no filter exposed

Divi is the third builder (chosen over a throwaway Beaver spike because it is
shipped code with real users). It implements 2 of 5 and gains no dead code, which
is the evidence the base contract is not Elementor-shaped.

What proved genuinely shared

  • Authorization: post type + login + edit_post on that specific popup.
  • Query restoration, including post_status for drafts.
  • Canvas selection and duplicate-render suppression.
  • Content routing to the owning builder.
  • Batch policy: dedupe per popup, flush at two boundaries, at most once each.
  • Signed preview URL minting/verification.

What stayed builder-specific

  • Availability probing (constant vs did_action vs theme-or-plugin).
  • Post type opt-in mechanism.
  • Document rendering call.
  • Asset finalization action (re-invoke vs generate-and-print).
  • Widget re-initialization.

Removed / merged from #1271

Removed Replaced by
Compatibility/Builder/Concerns/BuilderPreview.php (trait) Services\BuilderPreviewUrl + coordinator
Compatibility/Builder/Concerns/AssetBatching.php (trait) coordinator batching + LoadsDocumentAssets
Compatibility/Builder/Elementor.php Builders\Elementor
Compatibility/Builder/Divi.php Builders\Divi
Integration/Builder/Bricks.php (legacy, disabled) Builders\Bricks
Page_Builder_Preview_Test.php (tested the trait) Builder_Providers_Test (tests real contracts)

PUM_Previews keeps working: deprecated methods forward, and the two that moved
(allow_builder_preview_request, use_builder_preview_template) forward to the
coordinator. Previews is now scoped to the core editor's popup_preview flow
only — preview handling and frontend asset loading are no longer conflated.

Request lifecycle: before → after

#1271 This PR
Preview/builder hooks 8 in Previews + 2 per builder trait 6 in Builders, 1 per provider
Asset boundaries wp_enqueue_scripts:12 + wp_footer:0 per builder same two, owned once by the coordinator
Builder request recognition popup_maker/builder_preview_id filter chain direct registry iteration
Authorization sites trait + controller coordinator only

Measured overhead (Bricks, warm)

Builder popups Time Finalization passes
0 0.01 ms 0
1 ~0.4 ms 1
6 2.56 ms 1

Six popups finalize once, not six times — asserted in tests, not just measured.

Verification

Browser-tested against Bricks 1.12.4 + Elementor 4.1.3 on WordPress 7.0.

Bricks canvas (?bricks=run&brickspreview=1):

  • renders the popup only, no theme shell (#brx-header/#brx-footer absent)
  • generated Bricks element CSS applies (color: rgb(204,0,0), font-size: 32px)
  • Popup Maker theme intact (background: rgb(249,249,249), padding: 18px)
  • triggers: [] — no live triggers in the canvas
  • close button visible with aria-disabled="true"

Bricks page + 2 Bricks popups + 1 non-Bricks popup on one request:

  • main page renders; #brx-content appears exactly once
  • popup CSS applied, emitted once (no double emission)
  • non-Bricks popup untouched
  • 33 scoped BricksFunction.run() calls on open, 0 unscoped, 0 JS errors

Security (anonymous):

  • popup permalink → 404
  • ?elementor-preview → 404
  • unsigned ?pum-builder-preview → 404
  • ?bricks=run → 302 to home with no content leaked (Bricks' own guard)

Results

  • PHPUnit: 887 tests / 1843 assertions pass with Bricks active; 887 pass with
    Bricks absent (Bricks tests skip → no-op requirement proven).
  • PHPCS: clean on all changed files.
  • PHPStan: 40 errors, identical to the pre-existing baseline; 0 in new code.
  • ESLint: clean.
  • Build: production build succeeds.

Local AI review

CodeRabbit and Codex (gpt-5.6-sol, high) both reviewed the branch. Codex could not
refute the access-control design. Seven valid findings were fixed and covered by
regression tests:

  1. Popup Maker cleared Bricks' shared Assets::$inline_css['popup'] bucket,
    which Bricks also writes to and reads back — could double-emit or discard
    Bricks' own CSS. Now captures only its own delta.
  2. render_data() clobbers Frontend::$elements/$area without restoring; this
    render can be nested. Now snapshotted, as Bricks does for its own nested
    templates.
  3. Popup was missing from Assets::$page_settings_post_ids, so its Bricks page
    settings CSS was skipped.
  4. The runtime post-type injection could be persisted by Bricks'
    read-modify-write option paths and survive deactivation. Now stripped on write,
    while respecting a genuine owner opt-in.
  5. $post restoration was not exception-safe.
  6. Bricks widget re-init swept the whole page instead of the opened popup.
  7. Elementor widgets could accumulate duplicate handlers on reopen.

Remaining architectural concern

Two capability couplings a fourth builder would expose, documented rather than
speculatively abstracted:

  • LoadsDocumentAssets is only reached after RendersDocuments returns markup. A
    builder that renders through its own the_content hooks but still needs
    secondary-document CSS would have to implement RendersDocuments to get there.
  • Incoming signed previews are recognized inside EditsPopups::get_requested_popup_id(),
    so a REST/admin-only builder wanting signed previews must implement EditsPopups.

Also assumed: a p/post_type query rewrite, one main-query popup, a binary
shell-vs-canvas split, and two asset boundaries. A Breakdance-style builder with a
distinct editor route would need these loosened. I would rather loosen them against
a real fourth builder than guess now.

Test URLs

wp-env on isolated ports (.wp-env.override.json is gitignored and local-only):

npx wp-env start          # http://localhost:8981  (admin/password)
  • Bricks canvas: /?post_type=popup&p=15&bricks=run&brickspreview=1
  • Bricks page + popups: /bricks-page-with-popup/
  • Run Bricks tests: PUM_TEST_THEME=bricks vendor/bin/phpunit -c tests/php/phpunit.xml

🤖 Generated with Claude Code

…layer

Popup Maker's page builder integrations each reimplemented the same lifecycle:
recognize a builder request, authorize it, restore the otherwise non-public
popup query, render an isolated canvas, and load assets for popups that are not
the main query post. The Elementor work expressed that lifecycle as a base
controller plus traits, which fit Elementor but assumed Elementor's mechanics.

Adding Bricks made those assumptions visible. Bricks ships as a theme, gates
editing on a site-owner setting, returns markup from its renderer where
Elementor returns a string from a different API, self-gates its one-time
template bootstrap so it cannot be re-run, and emits no per-element document CSS
after wp_head. A shared "re-run the builder's asset handler" trait cannot
express both builders.

Model builders on the existing form-provider architecture instead: a registered
provider contract, a provider registry, thin builder adapters, and a coordinator
that owns the request lifecycle.

- Add BuilderProvider plus five optional capability interfaces, so a builder
  declares only what it can do. Divi implements two of five and gains no dead
  code.
- Add a BuilderProviders registry and a Builders coordinator owning
  authorization, query restoration, canvas selection, content routing, and asset
  batching. Providers answer questions and perform one operation each.
- Extract signed preview URLs into a stateless BuilderPreviewUrl service. The
  nonce binds popup ID and provider key, so a signature cannot be replayed
  across popups or builders.
- Narrow Previews to the core editor's popup_preview flow and delegate builder
  questions to the coordinator, keeping preview handling and frontend asset
  loading as separate concepts.
- Remove the builder compatibility controllers and the BuilderPreview and
  AssetBatching traits they shared.

Behavior changes beyond the refactor:

- Draft popups are queryable by an authorized editor. Builders open unsaved
  documents, which would otherwise 404.
- Builder canvases receive no triggers, so an auto-open or time-delay trigger
  cannot reopen the canvas mid-edit.
- Elementor widgets are marked once initialized, so reopening a popup no longer
  attaches duplicate handlers.

Verified against Bricks 1.12.4 and Elementor 4.1.3: the canvas renders only the
popup with its theme applied and no theme shell, generated Bricks element CSS
reaches the browser, a Bricks page and two Bricks popups coexist without
duplicating #brx-content, anonymous requests to popup permalinks and builder
URLs return 404 or redirect without leaking content, and six builder popups
finalize assets in one pass rather than six.

PHPUnit 875 tests / 1800 assertions pass. PHPCS clean. PHPStan unchanged at the
40-error baseline. ESLint clean. Production build succeeds.
- Scope Bricks widget reinitialization to the opened popup. Each registered
  BricksFunction accepts a `parentNode` override and tracks the elements it has
  already initialized, so reinitializing through that API avoids reprocessing the
  whole page on every popup open. Verified at runtime: 33 run() calls, all scoped
  to the opened popup, none unscoped.
- Restore the global $post in a finally block, so a throwing element or filter
  cannot leave the host page pointed at the popup.
- Correct the Bricks stub's render_data() return type. It returns markup rather
  than echoing, and the stub claimed void.
- Correct two stale cells in the discovery capability matrix.
…state

Adversarial review of the Bricks provider found four defects in how it shares
request-scoped state with Bricks. All are verified against Bricks 1.12.4 source
and covered by new tests that run against the real theme.

Assets::$inline_css['popup'] is Bricks' shared popup bucket, not ours: Bricks
appends its own native popup CSS to it (assets.php:474) and reads it back in two
places (assets.php:572, popups.php:928) without ever clearing it. Reading and
clearing the whole bucket could therefore emit Bricks' CSS a second time, or
discard CSS Bricks had not yet emitted. Capture only the delta this provider
generates, hand the bucket back unchanged, and buffer our own CSS locally.

render_data() overwrites Frontend::$elements and $area with the tree it is
rendering and does not restore them. This render can be nested, because
preloading a popup discovered from a popmake-ID trigger happens while the host
page's element tree is still rendering, so returning with a clobbered element map
could drop the host page's remaining children. Snapshot and restore both, the way
Bricks does around its own nested template renders (templates.php:263-268).

Bricks generates page-level CSS only for post IDs in
Assets::$page_settings_post_ids (assets.php:857), so a popup's own Bricks page
settings were skipped. Register the popup ID during collection.

The popup post type was injected into Bricks' settings on read. Bricks has
read-modify-write paths that get_option() those settings and update_option() the
whole array back, such as its Instagram token refresh
(integrations/instagram/instagram.php:95-101), which would have persisted the
injection and left it behind after deactivation. Strip it again on write, while
preserving the value when the site owner enabled it themselves.

Also bail early when the popup post cannot be loaded, rather than rendering with
a null global post and restoring the wrong state.

Testing: add Bricks_Provider_Test with twelve regression tests, and let the suite
run against a real theme via PUM_TEST_THEME. Bricks strips element data saved by
users who cannot execute code, so those fixtures run as an administrator.

887 tests pass with Bricks active (1843 assertions) and with Bricks absent, where
the Bricks tests skip. PHPCS clean. PHPStan unchanged at the 40-error baseline.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ce04a217-c3ce-42ec-b3bd-4685a449ba05

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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