Excuse me, do you have a moment to talk about our Lord and Saviour, WP_Rewrite?

WP Rewrite is dark voodoo magic. I screwed up, so you don't have to!

Excuse me, do you have a moment to talk about our Lord and Saviour, WP_Rewrite?

You know that part of every web based application where you tell it what to do when a request comes in on a certain URL?

This is Express.js (a Node.js package)

app.get('/', function (req, res) {
  res.send('root')
});

app.get('/about', function (req, res) {
  res.send('about')
});

app.get('/users/:userId/books/:bookId', function (req, res) {
  res.send(req.params)
});

// Route path: /users/:userId/books/:bookId
// Request URL: http://localhost:3000/users/34/books/8989
// req.params: { "userId": "34", "bookId": "8989" }

This is Laravel:

$router->group(['middleware' => 'web'], function ($router) {

	// API Token Refresh...
	$router->put( '/spark/token', 'TokenSecretController@refresh' );

	// API Settings
	$router->get( '/settings/api/tokens', 'TokenSecretController@all' );
	$router->post( '/settings/api/token', 'TokenSecretController@store' );
	$router->put( '/settings/api/token/{token_id}', 'TokenSecretController@update' );
	$router->get( '/settings/api/token/abilities', 'TokenSecretAbilitiesController@all' );
	$router->delete( '/settings/api/token/{token_id}', 'TokenSecretController@destroy' );
	//

	$router->get( '/logs/{token?}', 'LogController@index');
});

This is React (technically react-router):

const Main = () => (
  <main>
    <Switch>
      <Route exact path='/' component={Home}/>
      <Route path='/roster' component={Roster}/>
      <Route path='/schedule' component={Schedule}/>
    </Switch>
  </main>
)

See? It’s super easy.

... and then there’s WordPress

WordPress and rewrites

It’s basically dark voodoo magic. The whole mapping of what should happen given URLs is stored in the database that is first fetched at some point, then parsed, then the URL is matched against it, and then the template loader (see this post about the template loader) decides what to do with it, but plugins / themes have a chance to change that, so you don’t really have one file you can just open and LOOK AT to figure out what will happen.

On top of that, the whole WP_Rewrite object is full of arrays of arrays with keys of regex and values of MORE regex. I’m not kidding, look at this:

wp rewrite rules: regex to regex

Also some of these might overwrite others, some have higher priority than others, and there’s no telling where each of those came from.

To add a rewrite rule, you need to touch WordPress in 4 different places:

  1. add an endpoint (add_endpoint / add_rewrite_endpoint / ? / ¯\_(ツ)_/¯)
  2. add a rewrite rule (add_rewrite_rule / add_rewrite_tag / ? / ¯\_(ツ)_/¯ maybe?)
  3. add a query var (filter the global variable with query_vars)
  4. and flush the rewrite rules to move these additions into the database

Oh, by the way, the query vars need to be declared for every request.

But what can this do?

I was debugging a fairly weird issue that I couldn’t pin down for a while. Initially I was at a loss. The plugin hooks into WooCommerce, and basically adds another endpoint. I have trailing slashes on my site (oh, those count too! Guess where THAT’s configured? Stay tuned for the grand reveal at the end).

When I save the settings, here’s what would happen:

  1. save setting
  2. go to /permalink-that-should-work/, get 404 (redirect to front page)
  3. try again on /permalink-that-should-work/, succeed. What

Or

  1. save setting
  2. go to /permalink-that-should-work (no trailing slash), totally expecting it to fail, because the above did too
  3. succeed

But I was flushing the rewrite rules! HARD! (flush_rewrite_rules( true )).

Why?

Because the order of things was all wrong I didn’t know enough about permalinks, and it took me 7 hours of digging into all of WordPress to solve it. Ideally you’d want to add your own permalink rules before flushing the rules.

When you save settings in a WooCommerce tab (to which my plugin attached its settings page), it calls WC_Admin_Settings::save. In that method there are a few hooks available, one of them being ’woocommerce_update_options_’ . $current_tab. That’s where I hook in to save my plugin’s data, and flush the rewrite rules.

This save method is fired in the settings_page_init method on WC_Admin_Menus class, which it adds to the hook fired when loading the settings page.

The hook firing when loading the settings page happens in wp-admin/admin.php line 212 (in 4.8.1): do_action( "load-{$page_hook}" );

$page_hook is the result of a function call to get_plugin_page_hook.

That depends on get_plugin_page_hookname, which depends on get_admin_page_parent, and then does a bit of regex...

All of THAT runs because we’re literally hitting the wp-admin/admin.php file.

Which loads wp-load.php as one of the first things, so settings and early hooks will run.

However all of the above runs before any of the other code that’s responsible for register my new rewrite rule, so they just linger, unsaved.

WooCommerce schedules a single event for now to flush the rewrite rules.

WordPress cron jobs run on init though (see default-filters.php file).

Order of operations

Which also means that this is the order of important operations here when I click the "Save settings" button:

  1. wp-admin/admin.php gets called
  2. ../wp-load.php gets required
  3. wp-settings.php runs which requires wp-includes/functions.php and then checks whether WP is installed, and options get autoloaded there, from what I can see, the earliest. Rewrite rules are autoloaded. This is the data that’s going to be used from then on
  4. plugins_loaded runs. My plugin would normally add the rules here, but this is an admin request, so I didn’t include it
  5. init runs (and thus WP Cron gets called / done)
  6. admin page hook gets called
  7. WC_Admin_Settings::save invoked
  8. settings are saved
  9. a flush_rewrite_rules() ran from my plugin in the admin
  10. that causes the rewrite rules to reset to empty, and rebuild from scratch. My plugin’s routes are not here because the files aren’t included, because this is an admin request and I only include them if ! is_admin()
  11. WC()->query->init_query_vars called which sets WC’s query vars
  12. WC()->query->add_endpoints called, which adds endpoints for WC query vars
  13. the woocommerce_flush_rewrite_rules action is scheduled to run next time Cron runs on init
  14. rest of the request

1. New page, loading admin

  1. wp-settings.php runs which requires wp-includes/functions.php and then checks whether WP is installed, and options get autoloaded there, from what I can see, the earliest. Rewrite rules are autoloaded. This is the data that’s going to be used from then on
  2. plugins_loaded runs. My plugin would normally add the rules here, but this is an admin request, so I didn’t include it
  3. init runs, Cron triggers, spawning a new thread with it
  4. on the new Cron thread plugins_loaded runs again, and because Cron is not an admin request, is_admin() is false, my files get included, and thus the query vars added
  5. Cron triggers the previously scheduled woocommerce_flush_rewrite_rules action
  6. that triggers flush_rewrite_rules, and because my query vars and endpoints have been added, my permalink works

2. New page, loading my permalink WITH the trailing /

Assuming we’re right after having saved the option.

  1. wp-settings.php runs which requires wp-includes/functions.php and then checks whether WP is installed, and options get autoloaded there, from what I can see, the earliest. Rewrite rules are autoloaded. This is the data that’s going to be used from then on
  2. plugins_loaded runs, my plugins adds the QV and endpoints
  3. init runs, Cron spawned, separate thread, not relevant
  4. parse_request runs, the rewrite rules are fetched from the options table. Since the last time we saved it we were in the admin area, my permalinks aren’t there. The Cron spawned, but it’s a separate thread, it’s non-blocking, and it’s not updating my request
  5. I get redirected to the front page
  6. The next request to the same URL WILL have my permalinks in it, because the flush rewrite rules attached to the Cron would have updated it

3. New page, loading my permalink WITHOUT the trailing /

Mostly the same as above, except in step 4, instead of being redirected to the front page, I get redirected to the same page but WITH the trailing slash:

https://foobar.dev/some-page -> https://foobar.dev/some-page/

The one with the trailing slash is another request, by which time Cron kicked off on the page without the trailing slash would have done its thing.

Fixing it

Two possible approaches:

  1. I just need to remove the call to flush_rewrite_rules from within my plugin when the settings are saved. That way the previous permalinks are still there, so mine aren’t wiped, or
  2. I include the files responsible for setting up the permalinks on the admin side too. Otherwise how would WP know about those settings when spamming the "Save Permalinks" button?

I’ve gone with the latter.

P.S.: whether you have trailing slashes on ALL your links or not depends on what you set here:

Image-2017-09-15-at-1.14.39-am.public

Photo by Jason Leung from Unsplash