Cache Buster

Bypass your website cache when loading a Gravity Forms form.

Read the Walkthrough

Code

Filename: gw-cache-buster.php

<?php
/**
 * Gravity Wiz // Gravity Forms // Cache Buster
 * https://gravitywiz.com/cache-busting-with-gravity-forms/
 *
 * Bypass your website cache when loading a Gravity Forms form.
 *
 * Plugin Name: Gravity Forms Cache Buster
 * Plugin URI:  https://gravitywiz.com/cache-busting-with-gravity-forms/
 * Description: Bypass your website cache when loading a Gravity Forms form.
 * Author:      Gravity Wiz
 * Version:     0.6.1
 * Author URI:  https://gravitywiz.com
 */
class GW_Cache_Buster {

	private $_args = array();

	public function __construct( $args = array() ) {

		// set our default arguments, parse against the provided arguments, and store for use throughout the class
		$this->_args = wp_parse_args(
			$args,
			array(
				'form_id'  => false,
				'field_id' => false,
			)
		);

		// do version check in the init to make sure if GF is going to be loaded, it is already loaded
		add_action( 'init', array( $this, 'init' ) );

	}

	public function init() {

		// make sure we're running the required minimum version of Gravity Forms
		if ( ! property_exists( 'GFCommon', 'version' ) || ! version_compare( GFCommon::$version, '1.8', '>=' ) ) {
			return;
		}

		add_filter( 'gform_shortcode_form', array( $this, 'shortcode' ), 10, 3 );
		add_filter( 'gform_save_and_continue_resume_url', array( $this, 'filter_resume_link' ), 15, 4 );
		add_filter( 'gform_pre_replace_merge_tags', array( $this, 'replace_embed_url' ), 10, 4 );
		add_action( 'gform_after_submission', array( $this, 'entry_source_url' ), 10, 2 );

		add_action( 'wp_ajax_nopriv_gfcb_get_form', array( $this, 'ajax_get_form' ) );
		add_action( 'wp_ajax_gfcb_get_form', array( $this, 'ajax_get_form' ) );
	}

	/**
	 * Duplicate method due to GFFormDisplay::set_form_styles() being private.
	 *
	 * Applies the form styles and form theme to the form object so that the proper style block is rendered to the page.
	 *
	 * @param mixed       $form           The form object.
	 * @param string|null $style_settings Style settings for the form. This will either come from the shortcode or from the block editor settings.
	 * @param string|null $form_theme     The theme selected for the form. This will either come from the shortcode or from the block editor settings.

	 * @return array Returns the form object with the 'styles' and 'theme' properties set.
	 */
	public function set_form_styles( $form, $style_settings, $form_theme ) {
		if ( $style_settings === false ) {
			// Form styles specifically set to false disables inline form css styles.
			$form_styles = false;
		} else {
			$form_styles = !empty( $style_settings ) ? json_decode( $style_settings, true ) : array();
		}

		// Removing theme from styles for consistency. $form['theme'] should be used instead.
		if ( $form_styles ) {
			unset( $form_styles['theme'] );
		}
		$form['styles'] = GFFormDisplay::get_form_styles( $form_styles );
		$form['theme']  = $form_theme;
		return $form;
	}

	public function shortcode( $markup, $attributes, $content ) {

		$atts = shortcode_atts(
			array(
				'id'          => 0,
				'cachebuster' => false,

				// Attributes needed for theme/styles.
				'theme'        => method_exists( 'GFForms', 'get_default_theme' ) ? GFForms::get_default_theme() : '',
				'styles'       => '',
			),
			$attributes
		);

		$form_id = $atts['id'];

		if ( ! $this->is_cache_busting_applicable() ) {
			return $markup;
		}

		$is_enabled = rgar( $atts, 'cachebuster' ) || gf_apply_filters( array( 'gfcb_enable_cache_buster', $form_id ), false, $form_id );
		if ( ! $is_enabled ) {
			return $markup;
		}

		add_filter( "gform_footer_init_scripts_filter_{$form_id}", array( $this, 'suppress_default_post_render_event' ), 10, 3 );

		ob_start();
		?>

		<div id="gf-cache-buster-form-container-<?php echo $form_id; ?>" class="gf-cache-buster-form-container">
			<div class="loader"></div>
			<style type="text/css">
				.gf-cache-buster-form-container { }
				.loader,
				.loader:before,
				.loader:after {
					border-radius: 50%;
					width: 2.5em;
					height: 2.5em;
					-webkit-animation-fill-mode: both;
					animation-fill-mode: both;
					-webkit-animation: load7 1.8s infinite ease-in-out;
					animation: load7 1.8s infinite ease-in-out;
				}
				.loader {
					color: rgba( 0, 0, 0, 0.5 );
					font-size: 10px;
					margin: 0 auto 80px;
					position: relative;
					text-indent: -9999em;
					-webkit-transform: translateZ(0);
					-ms-transform: translateZ(0);
					transform: translateZ(0);
					-webkit-animation-delay: -0.16s;
					animation-delay: -0.16s;
				}
				.loader:before,
				.loader:after {
					content: '';
					position: absolute;
					top: 0;
				}
				.loader:before {
					left: -3.5em;
					-webkit-animation-delay: -0.32s;
					animation-delay: -0.32s;
				}
				.loader:after {
					left: 3.5em;
				}
				@-webkit-keyframes load7 {
					0%,
					80%,
					100% {
						box-shadow: 0 2.5em 0 -1.3em;
					}
					40% {
						box-shadow: 0 2.5em 0 0;
					}
				}
				@keyframes load7 {
					0%,
					80%,
					100% {
						box-shadow: 0 2.5em 0 -1.3em;
					}
					40% {
						box-shadow: 0 2.5em 0 0;
					}
				}
			</style>
		</div>
		<?php
		// Include original query parameters (with some exclusions) in the AJAX call to preserve dynamic population via query string.
		$exclude_params = array( 'action', 'form_id', 'atts' );
		$ajax_url       = remove_query_arg( $exclude_params, add_query_arg( $_GET, admin_url( 'admin-ajax.php' ) ) );

		// Get the form theme.
		if ( method_exists( 'GFFormDisplay', 'get_form_theme_slug' ) ) {
			$form = $this->set_form_styles( GFAPI::get_form( $form_id ), rgar( $atts, 'styles' ), rgar( $atts, 'theme' ) );
			$form_theme = GFFormDisplay::get_form_theme_slug( $form );
		} else {
			$form_theme = null;
		}

		// Still needed for the AJAX submission.
		$ajax_params = array(
			'action'  => 'gfcb_get_form',
			'form_id' => $form_id,
			'form_theme' => $form_theme,
		);

		// Ensure AJAX parameters for GPNF are also correctly populated.
		if ( class_exists( 'GPNF_Session' ) ) {
			$ajax_params['gpnf_context'] = array(
				'path' => esc_js( GPNF_Session::get_session_path() ),
			);
		}

		$ajax_url = add_query_arg( $ajax_params, $ajax_url );

		$lang = null;
		if ( class_exists( 'Gravity_Forms_Multilingual' ) ) {
			global $sitepress;
			$lang = $sitepress->get_current_language();
		}
		?>
		<script type="text/javascript">
			( function ( $ ) {
				var formId = '<?php echo $form_id; ?>';
				$.post( '<?php echo $ajax_url; ?>', {
					action: 'gfcb_get_form',
					form_id: '<?php echo $form_id; ?>',
					atts: <?php echo wp_json_encode( $attributes ); ?>,
					form_request_origin: '<?php echo esc_js( GFCommon::openssl_encrypt( GFFormsModel::get_current_page_url() ) ); ?>',
					lang: '<?php echo $lang; ?>'
				}, function( response ) {
					$( '#gf-cache-buster-form-container-<?php echo $form_id; ?>' ).html( response ).fadeIn();
					if( window['gformInitDatepicker'] ) {
						gformInitDatepicker();
					}
					// Initialize GPPA
					// @todo Since we are not triggering the `gform_post_render` below, I'm not certain that we need this.
					if( response.indexOf('GPPA') > -1 ) {
						window.gform.doAction('gppa_register_form', formId);
					}
					// We probably don't need this since everything else should already be loaded by this point but since
					// GF is using it as their standard for triggering the `gform_post_render` event, I figured we should follow suit.
					gform.initializeOnLoaded( function() {
						// Form has been rendered. Trigger post render to initialize scripts.
						<?php
							echo sprintf(
								'gform.initializeOnLoaded(function() {%s});',
								GFFormDisplay::post_render_script(
									$form_id,
									GFFormDisplay::get_current_page( $form_id )
							) );
						?>
					} );
				} );
			} )( jQuery );
		</script>

		<?php
		return ob_get_clean();
	}

	public function is_cache_busting_applicable() {
		// POSTED and LOGGED-IN requests are not typically cached
		return empty( $_POST ) || ! is_user_logged_in();
	}

	public function suppress_default_post_render_event( $form_string, $form, $current_page ) {
		$searches = array(
			"gform.initializeOnLoaded( function() { jQuery(document).trigger('gform_post_render', [{$form['id']}, {$current_page}]) } );",
			"gform.initializeOnLoaded( function() {jQuery(document).trigger('gform_post_render', [{$form['id']}, {$current_page}]);gform.utils.trigger({ event: 'gform/postRender', native: false, data: { formId: {$form['id']}, currentPage: {$current_page} } });} );",
		);

		foreach ( $searches as $search ) {
			$search      = GFCommon::get_inline_script_tag( $search );
			$form_string = str_replace( $search, '', $form_string );
		}

		if ( is_callable( 'GFFormDisplay::post_render_script' ) ) {
			$post_render_script = GFFormDisplay::post_render_script( $form['id'], $current_page );
			$post_render_script = preg_quote( $post_render_script, '/' ); // Escape special characters
			$pattern            = '/<script>\s*gform\.initializeOnLoaded\(\s*function\(\)\s*\{\s*(' . $post_render_script . ')\s*\}\s*\);\s*<\/script>/';
			$form_string        = preg_replace( $pattern, '', $form_string );
		}

		return $form_string;
	}

	public function ajax_get_form() {

		$form_id = isset( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0;
		if ( ! $form_id ) {
			$form_id = isset( $_GET['form_id'] ) ? absint( $_GET['form_id'] ) : 0;
		}

		add_filter( 'gpasc_new_draft_form_path', function( $form_path, $form ) {
			return rgpost( 'form_request_origin' )
				? remove_query_arg( 'gf_token', GFCommon::openssl_decrypt( rgpost( 'form_request_origin' ) ) )
				: $form_path;
		}, 10, 2 );

		/**
		 * Init scripts are output to the footer by default so they are not needed in the AJAX response. Some plugins
		 * deliberately output inline scripts alongside the form (see Nested Forms' `gpnf_preload_form` filter) but I
		 * haven't encountered a scenario where this is ideal when fetching the form via an AJAX request like we do here.
		 *
		 * Additionally, to support this change, we manually trigger the `gform_post_render` event after we've loaded
		 * the form markup from this AJAX response.
		 *
		 * Priority of this filter is set aggressively high to ensure it will take priority.
		 */
		add_filter( 'gform_init_scripts_footer', '__return_true', 987 );
		add_filter( 'gform_form_tag_' . $form_id, array( $this, 'add_hidden_inputs' ), 10, 2 );
		add_filter( 'gform_pre_render_' . $form_id, array( $this, 'replace_embed_tag_for_field_default_values' ) );

		add_filter( 'gform_form_theme_slug', function( $slug, $form ) {
			return rgar( $_REQUEST, 'form_theme' ) ?: $slug;
		}, 10, 2 );

		$atts = rgpost( 'atts' );

		// GF expects an associative array for field values. Parse them before passing it on.
		$field_values = wp_parse_args( rgar( $atts, 'field_values' ) );

		// If `$_POST` is not an empty array GF 2.5 fails to select default values for checkbox fields. See HS#26188
		$GLOBALS['GWCB_POST'] = $_POST;
		$_POST                = array();

		gravity_form( $form_id, filter_var( rgar( $atts, 'title', true ), FILTER_VALIDATE_BOOLEAN ), filter_var( rgar( $atts, 'description', true ), FILTER_VALIDATE_BOOLEAN ), false, $field_values, true /* default to true; add support for non-ajax in the future */, rgar( $atts, 'tabindex' ) );

		remove_filter( 'gform_form_tag_' . $form_id, array( $this, 'add_hidden_inputs' ) );
		remove_filter( 'gform_pre_render_' . $form_id, array( $this, 'replace_embed_tag_for_field_default_values' ) );

		die();
	}

	/**
	 * Since the form is loading admin-ajax.php, GFFormsModel::get_current_page_url() will return the wrong URL when
	 * saving and continuing.
	 *
	 * We need to replace the resume link with the page loading the AJAX form.
	 *
	 * @param string $resume_url The URL to be used to resume the partial entry.
	 * @param array $form The Form Object.
	 * @param string $resume_token The token that is used within the URL.
	 * @param string $email The email address associated with the partial entry.
	 */
	public function filter_resume_link( $resume_url, $form, $resume_token, $email ) {
		if ( rgar( $_REQUEST, 'action' ) !== 'gfcb_get_form' ) {
			return $resume_url;
		}

		$referer = rgar( $_SERVER, 'HTTP_REFERER' );

		if ( ! $referer ) {
			return $resume_url;
		}

		return add_query_arg( array( 'gf_token' => $resume_token ), $referer );
	}

	/**
	 * Replace `{embed_url}` merge tag for notifications, confirmations, etc.
	 *
	 * @param string $text The current text with merge tags.
	 * @param array $form The current form.
	 * @param array $entry The current entry.
	 * @param boolean $url_encode The URLs need to be encoded or not.
	 *
	 * @return string
	 */
	public function replace_embed_url( $text, $form, $entry, $url_encode ) {
		// Check if the text contains the {embed_url} merge tag
		if ( strpos( $text, '{embed_url}' ) !== false ) {
			$origin = $this->get_form_request_origin();

			if ( ! $origin ) {
				return $text;
			}

			// Replace the {embed_url} merge tag with the original URL
			$text = str_replace( '{embed_url}', $origin, $text );
		}

		return $text;
	}

	/**
	 * Append hidden inputs to store the entry url.
	 *
	 * @param array $entry The current entry.
	 * @param array $form The current form.
	 */
	function entry_source_url( $entry, $form ) {
		$origin = $this->get_form_request_origin();

		if ( ! $origin ) {
			return;
		}

		GFAPI::update_entry_property( $entry['id'], 'source_url', $origin );
	}

	/**
	 * Replace {embed_url} and {embed_post} merge tag for field default values. We have to do this using gform_pre_render as
	 * gform_pre_replace_merge_tags is not called for field default values.
	 *
	 * @param array $form The current form.
	 */
	public function replace_embed_tag_for_field_default_values( $form ) {
		foreach ( $form['fields'] as &$field ) {
			$text    = $field->defaultValue;
			$origin  = $this->get_form_request_origin();
			$post_id = url_to_postid( $origin );

			if ( strpos( $text, '{embed_url}' ) !== false ) {
				if ( ! $origin ) {
					continue;
				}

				$field->defaultValue = str_replace( '{embed_url}', $origin, $text );
			}

			preg_match_all( '/\{embed_post:(.*?)\}/', $text, $ep_matches, PREG_SET_ORDER );

			if ( $ep_matches && $post_id ) {
				$post       = get_post( $post_id );
				$post_array = GFCommon::object_to_array( $post );

				foreach ( $ep_matches as $match ) {
					$full_tag = $match[0];
					$property = $match[1];
					$value    = $post_array[ $property ];
					$text     = str_replace( $full_tag, $value, $text );
				}

				$field->defaultValue = $text;
			}
		}

		return $form;
	}

	/**
	 * Helper method to get the form request origin.
	 */
	public function get_form_request_origin() {
		static $form_request_origin;

		if ( $form_request_origin ) {
			return $form_request_origin;
		}

		$origin = rgpost( 'gwcb_form_request_origin' );
		$origin = $origin ?: rgpost( 'form_request_origin' );
		$origin = $origin ?: rgars( $GLOBALS, 'GWCB_POST/form_request_origin' );

		if ( $origin ) {
			$form_request_origin = GFCommon::openssl_decrypt( $origin );
		} else {
			$form_request_origin = null;
		}

		return $form_request_origin;
	}

	/**
	 * Append hidden inputs to store the entry url.
	 *
	 * @param string $form_tag The form opening tag.
	 * @param array $form The current form.
	 *
	 * @return string
	 */
	public function add_hidden_inputs( $form_tag, $form ) {
		if ( strpos( $form_tag, 'gwcb_form_request_origin' ) !== false ) {
			return $form_tag;
		}

		$form_tag .= '<input type="hidden" value="' . esc_attr( rgars( $GLOBALS, 'GWCB_POST/form_request_origin' ) ) . '" name="gwcb_form_request_origin" />';
		return $form_tag;
	}
}

# Configuration

new GW_Cache_Buster();

# Enable Cache Buster on all forms.
//add_filter( 'gfcb_enable_cache_buster', '__return_true' );

# Enable Cache Buster on all forms with exceptions.
//add_filter( 'gfcb_enable_cache_buster', function( $should_enable, $form_id ) {
//	if ( ! in_array( $form_id, array( 493, 124, 125 ) ) ) {
//		$should_enable = true;
//	}
//	return $should_enable;
//}, 10, 2 );
Tags:

Leave a Reply

Your email address will not be published. Required fields are marked *

  • Trouble installing this snippet? See our troubleshooting tips.
  • Need to include code? Create a gist and link to it in your comment.
  • Reporting a bug? Provide a URL where this issue can be recreated.

By commenting, I understand that I may receive emails related to Gravity Wiz and can unsubscribe at any time.