PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.7.2
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.7.2
3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 3.1.10 All 111 releases
← All changes | includes/Builder/TemplateLoader.php +293 -4 3.6.43.7.2 View file →
@@ -14,8 +14,86 @@
14 14 * @var Views
15 15 */
16 16 private $views;
17 17
18 + /**
19 + * Stack of booleans, one per in-flight document render, recording whether
20 + * that call switched the main query to a preview product. A stack (not a
21 + * single flag) so nested/re-entrant renders — e.g. a `product_single`
22 + * template embedding another Elementor document via a Template widget —
23 + * restore correctly. Static ( not per-instance ) because the hooks that
24 + * drive it are registered unconditionally ( register_preview_product_hooks() ),
25 + * independent of whether a per-request TemplateLoader instance exists.
26 + *
27 + * @var bool[]
28 + */
29 + private static $preview_query_switch_stack = [];
30 +
31 + /**
32 + * Registers the WooCommerce preview-product query switch, unconditionally
33 + * ( admin and front end alike — unlike the rest of this class, which is only
34 + * instantiated on the front-end `wp` hook ). Called once from ThemeBuilder's
35 + * constructor.
36 + *
37 + * Three independent render pathways need their own switch, because none of
38 + * them share a common choke point:
39 + * - Real front end / live preview iframe: Document::get_content() ->
40 + * Frontend::get_builder_content(). This is a normal front-end request, so
41 + * it WOULD also be reachable from a `wp`-gated instance, but is registered
42 + * here too for symmetry with the pathways below and so both share one stack.
43 + * - Editor admin page bootstrap ( post.php?action=elementor, which never
44 + * fires the `wp` action ): Document::get_config() bakes each widget's
45 + * server-rendered `render()` output into `ElementorConfig.initial_document`
46 + * as `htmlCache` via get_elements_raw_data( null, true ), fired right after
47 + * `before_get_config`. This does NOT go through Frontend::get_builder_content()
48 + * at all, so it needs its own switch/restore pair.
49 + * - Adding a widget to the LIVE canvas ( no save/reload in between ): a PHP-only
50 + * widget with no JS `content_template()` — every EA Woo widget, and most of
51 + * ours — is rendered via the `render_widget` ajax action
52 + * ( Widgets_Manager::ajax_render_widget() -> Document::render_element() ),
53 + * which goes through NEITHER get_content() NOR get_config(). Confirmed by
54 + * direct testing: dropping one of these widgets onto a brand-new, unsaved
55 + * product_single template shows the widget's placeholder until the whole
56 + * document is reloaded ( which bakes via get_config() and so IS covered ) —
57 + * reloading immediately after masks the gap, which is why it wasn't caught
58 + * sooner.
59 + *
60 + * Two other approaches were tried and confirmed NOT to work, by direct
61 + * testing with temporary logging (kept here so the next person doesn't
62 + * re-walk the same dead ends):
63 + * - Switching the query earlier ( e.g. on `wp_ajax_elementor_ajax` ), on the
64 + * theory that `ajax_render_widget()`'s own `query_posts( [ 'p' =>
65 + * $editor_post_id, 'post_type' => 'any' ] )` (widgets.php) would silently
66 + * undo it. Red herring: `query_posts()` only replaces `$wp_query` — it
67 + * does NOT touch `global $post` at all ( that only happens via
68 + * `the_post()` / `setup_postdata()`, neither of which `ajax_render_widget()`
69 + * calls on its query ) — confirmed via a `the_posts` filter showing the
70 + * query itself resolving to the right product while `$GLOBALS['post']`
71 + * stayed on the templately_library document throughout.
72 + * - Rewriting that same `query_posts()` query via `pre_get_posts`. The query
73 + * rewrite itself worked ( confirmed the SQL fetched the preview product
74 + * correctly ) but was moot for the same reason: since `query_posts()`
75 + * never touches `global $post`, redirecting it changes nothing observable.
76 + *
77 + * The actual cause: `Ajax\Module::handle_ajax_request()` calls
78 + * `Plugin::$instance->db->switch_to_post( $editor_post_id )` — which DOES
79 + * directly set `$GLOBALS['post']` + `setup_postdata()` — *before* dispatching
80 + * to any registered action (including `render_widget`), and nothing after
81 + * that point ever touches `global $post` again. So the fix has to override
82 + * `global $post` a second time, strictly *after* that call. `elementor/ajax/
83 + * register_actions` fires right after it and before the action dispatch loop
84 + * — see maybe_switch_global_post_for_ajax_render(). No restore needed: this
85 + * is a one-shot admin-ajax.php process that exits right after responding.
86 + */
87 + public static function register_preview_product_hooks() {
88 + add_action( 'elementor/frontend/before_get_builder_content', [ self::class, 'maybe_switch_to_preview_product_query' ] );
89 + add_filter( 'elementor/frontend/the_content', [ self::class, 'maybe_restore_preview_product_query' ] );
90 + add_action( 'elementor/document/before_get_config', [ self::class, 'maybe_switch_to_preview_product_query' ] );
91 + add_filter( 'elementor/document/config', [ self::class, 'maybe_restore_preview_product_query' ] );
92 +
93 + add_action( 'elementor/ajax/register_actions', [ self::class, 'maybe_switch_global_post_for_ajax_render' ] );
94 + }
95 +
18 96 public function __construct( $builder, $views ) {
19 97 $this->views = $views;
20 98
21 99 // new ThemeCompatibility();
@@ -132,14 +210,225 @@
132 210
133 211 public function set_global_product(){
134 212 global $product;
135 213
136 - if ( class_exists('\WC_Product') && ! $product instanceof \WC_Product ) {
137 - $product_id = get_the_ID();
138 - if ( $product_id ) {
139 - wc_setup_product_data( $product_id );
214 + if ( ! function_exists( 'wc_setup_product_data' ) ) {
215 + return;
216 + }
217 +
218 + // A real product is already in context (e.g. a live single-product page) — never override it.
219 + if ( $product instanceof \WC_Product ) {
220 + return;
221 + }
222 +
223 + $document_id = get_the_ID();
224 + if ( ! $document_id ) {
225 + return;
226 + }
227 +
228 + // The current post is itself a product — set it up (prior behaviour, kept explicit).
229 + if ( get_post_type( $document_id ) === 'product' ) {
230 + wc_setup_product_data( $document_id );
231 + return;
232 + }
233 +
234 + // Otherwise only fabricate a preview product for a WooCommerce single-product
235 + // Theme Builder template being rendered in the editor / preview. This mirrors what
236 + // Elementor Pro's Single Product document does: point the global $product at a real
237 + // product so any WooCommerce widget — ours and third-party (e.g. EA Woo Product Tabs) —
238 + // renders real data in the editor instead of appearing empty. The live front end is
239 + // unaffected (there a real product is always in context, handled above).
240 + //
241 + // This only sets `global $product`; it does NOT cover widgets/plugins that re-derive
242 + // the product from `global $post` (e.g. via `wc_get_product( false )`'s fallback, which
243 + // checks `get_post_type( $post->ID )`) instead of trusting a pre-set `$product`. That
244 + // gap is closed separately by maybe_switch_to_preview_product_query() below.
245 + if ( ! self::is_woo_single_preview( $document_id ) ) {
246 + return;
247 + }
248 +
249 + $preview_product_id = self::get_preview_product_id();
250 + if ( $preview_product_id ) {
251 + wc_setup_product_data( $preview_product_id );
252 + }
253 + }
254 +
255 + /**
256 + * Swap the main query ( and `global $post` ) to the preview product for the
257 + * duration of a `product_single` Theme Builder document's content render.
258 + *
259 + * Mirrors Elementor Pro's `Theme_Document::get_content()` / `Preview_Manager`
260 + * ( `switch_to_preview_query()` + `DB::switch_to_query( $query_vars, true )` ),
261 + * but built only on Elementor core APIs so it works without Elementor Pro.
262 + * This is what makes widgets that re-derive the product from `global $post`
263 + * ( e.g. EA's Woo Product Description, or `wc_get_product( false )`'s own
264 + * fallback ) render real content instead of a placeholder — setting
265 + * `global $product` alone ( set_global_product() above ) isn't enough for them.
266 + *
267 + * Hooked to both `elementor/frontend/before_get_builder_content` (real front end /
268 + * live preview iframe) and `elementor/document/before_get_config` (editor admin
269 + * page bootstrap), paired respectively with maybe_restore_preview_product_query()
270 + * on `elementor/frontend/the_content` and `elementor/document/config`.
271 + * `DB::switch_to_query()` / `restore_current_query()` are themselves stack-based,
272 + * but the hook only gives us the document on the "before" call, so a small
273 + * boolean stack here is what lets the "after" filter know whether to restore.
274 + *
275 + * @param \Elementor\Core\Base\Document|null $document The document about to render.
276 + */
277 + public static function maybe_switch_to_preview_product_query( $document ) {
278 + $switched = false;
279 +
280 + if ( $document && function_exists( 'wc_setup_product_data' ) ) {
281 + $document_id = $document->get_main_id();
282 +
283 + if ( self::is_woo_single_preview( $document_id ) ) {
284 + $preview_product_id = self::get_preview_product_id();
285 +
286 + if ( $preview_product_id ) {
287 + Plugin::$instance->db->switch_to_query( [
288 + 'p' => $preview_product_id,
289 + 'post_type' => 'product',
290 + ], true );
291 +
292 + $switched = true;
293 + }
140 294 }
141 295 }
296 +
297 + self::$preview_query_switch_stack[] = $switched;
298 + }
299 +
300 + /**
301 + * Restore the query switched by maybe_switch_to_preview_product_query(), if any.
302 + *
303 + * @param string $content Unmodified — this is a restore point, not a content filter.
304 + * @return string
305 + */
306 + public static function maybe_restore_preview_product_query( $content ) {
307 + if ( array_pop( self::$preview_query_switch_stack ) ) {
308 + Plugin::$instance->db->restore_current_query();
309 + }
310 +
311 + return $content;
312 + }
313 +
314 + /**
315 + * Whether `$document_id` is a WooCommerce single-product ( `product_single` )
316 + * Theme Builder template at all — regardless of edit/preview mode.
317 + *
318 + * @param int $document_id The templately_library post being checked.
319 + * @return bool
320 + */
321 + private static function is_woo_single_document( $document_id ) {
322 + if ( get_post_meta( $document_id, Source::TYPE_META_KEY, true ) !== 'product_single' ) {
323 + return false;
324 + }
325 +
326 + return post_type_exists( 'product' );
327 + }
328 +
329 + /**
330 + * Whether the current request is the editor/preview render of a WooCommerce
331 + * single-product ( `product_single` ) Theme Builder template.
332 + *
333 + * @param int $document_id The templately_library post being rendered.
334 + * @return bool
335 + */
336 + private static function is_woo_single_preview( $document_id ) {
337 + if ( ! self::is_woo_single_document( $document_id ) ) {
338 + return false;
339 + }
340 +
341 + return Plugin::$instance->editor->is_edit_mode()
342 + || Plugin::$instance->preview->is_preview_mode( $document_id )
343 + || ( isset( $_GET['templately_library'], $_GET['preview_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
344 + }
345 +
346 + /**
347 + * Third preview-product switch point: overrides `global $post` for the
348 + * `render_widget` ajax action — used when a widget with no JS
349 + * `content_template()` is freshly added to the live editor canvas ( see
350 + * register_preview_product_hooks() for the two dead ends this replaced ).
351 + *
352 + * `Ajax\Module::handle_ajax_request()` calls `Plugin::$instance->db
353 + * ->switch_to_post( $editor_post_id )` — which sets `$GLOBALS['post']` +
354 + * `setup_postdata()` directly — before dispatching to any action, and nothing
355 + * in `ajax_render_widget()` ( including its own `query_posts()` call, which
356 + * never touches `global $post` ) touches it again afterwards. So this has to
357 + * override `global $post` a second time, the same way `switch_to_post()`
358 + * itself does, timed to run strictly after it: `elementor/ajax/register_actions`
359 + * fires right after that call and before the action-dispatch loop.
360 + *
361 + * Reads `$_REQUEST['actions']` — the same raw JSON `Ajax\Module` itself decodes
362 + * moments later — read-only, purely to detect whether a `render_widget` call is
363 + * queued in this batch; no nonce check needed for that inspection since nothing
364 + * is mutated based on it beyond which product post to point `global $post` at.
365 + *
366 + * No restore call: a single, short-lived admin-ajax.php process that exits
367 + * right after responding, so nothing persists past it.
368 + */
369 + public static function maybe_switch_global_post_for_ajax_render() {
370 + if ( ! function_exists( 'wc_setup_product_data' ) || empty( $_REQUEST['actions'] ) || empty( $_REQUEST['editor_post_id'] ) ) {
371 + return;
372 + }
373 +
374 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only inspection, see docblock above.
375 + $actions = json_decode( wp_unslash( $_REQUEST['actions'] ), true );
376 +
377 + if ( ! is_array( $actions ) ) {
378 + return;
379 + }
380 +
381 + $has_render_widget = false;
382 +
383 + foreach ( $actions as $action_data ) {
384 + if ( isset( $action_data['action'] ) && 'render_widget' === $action_data['action'] ) {
385 + $has_render_widget = true;
386 + break;
387 + }
388 + }
389 +
390 + if ( ! $has_render_widget ) {
391 + return;
392 + }
393 +
394 + $document_id = absint( $_REQUEST['editor_post_id'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
395 +
396 + if ( ! self::is_woo_single_document( $document_id ) ) {
397 + return;
398 + }
399 +
400 + $preview_product_id = self::get_preview_product_id();
401 +
402 + if ( ! $preview_product_id ) {
403 + return;
404 + }
405 +
406 + $GLOBALS['post'] = get_post( $preview_product_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
407 +
408 + setup_postdata( $GLOBALS['post'] );
409 + }
410 +
411 + /**
412 + * Resolve the newest published product to use as the editor preview product.
413 + * Matches the "latest product" approach used by the dynamic Theme Builder
414 + * widgets ( Post_Content / Post_Title / Featured_Image ) and Elementor Pro.
415 + *
416 + * @return int Product ID, or 0 when the store has no published products.
417 + */
418 + private static function get_preview_product_id() {
419 + $products = get_posts( [
420 + 'post_type' => 'product',
421 + 'post_status' => 'publish',
422 + 'numberposts' => 1,
423 + 'orderby' => 'date',
424 + 'order' => 'DESC',
425 + 'fields' => 'ids',
426 + 'no_found_rows' => true,
427 + 'suppress_filters' => false,
428 + ] );
429 +
430 + return ! empty( $products ) ? (int) $products[0] : 0;
142 431 }
143 432
144 433 /**
145 434 * Print remaining enqueued styles with error handling.