All notable changes to Simple Custom Post Order are documented here. The full,
WordPress.org-formatted history lives in readme.txt; this file mirrors
recent releases in Keep a Changelog style and follows
Semantic Versioning.
- Enabling a post type destroyed the order it already had.
sanitize_options()seeded a newly enabled type bypost_title ASC(pages) orpost_date DESC(everything else), ignoringmenu_orderentirely — so ticking "Pages" in Settings silently alphabetised the whole site. For pages that is WordPress's own Page Attributes → Order field, and for a site arriving from another sorting plugin it is the order that plugin left behind. Worse, the loop runs for every enabled type on every settings save, not just newly enabled ones, so any later visit to the settings screen could re-flatten the order again. Seeding now preserves an existing sequence and only falls back to title/date when there is genuinely nothing to preserve — that is, when every row of the type shares a singlemenu_ordervalue, which is what an untouched site looks like. Reported by @martinsauter ("destroying any existing order") and diagnosed by @jamieburchell, who arrived from another sort plugin whose values started at0. - The two renumber paths broke ties differently.
refresh()orders onmenu_order ASC, ID ASC; the seeding above used a title/date tie-break. Both renumber the same rows, and a site hits whichever runs first, so two rows sharing amenu_ordercould come out in one order after a settings save and the other after simply viewing the list — an order that appears to change on its own. Both now break ties on ID. - 2.8.7 regression, same area. Adding
COUNT(DISTINCT)tois_already_sequential()in 2.8.7 was correct for repairing duplicates, but it also widened the set of sites the destructive seeding fired on: a type whose values were, say,{1,2,2,4}was skipped in 2.8.6 and reseeded in 2.8.7. With seeding no longer destructive this is moot in either direction, but it is worth recording that 2.8.7 briefly made the older bug easier to hit. - Dragging a parent in a page tree left its children behind until the page was reloaded (reported by @jamieburchell). WordPress renders a hierarchical list as one flat run of
<tr>s, so moving a parent moved only its own row. What was saved was always correct — the list table re-nests children under their parent on the next render andpost_parentis never touched — but the screen disagreed with it in the meantime, which read as a broken drag. The sorter now carries a row's whole subtree with it, on mouse drag, on keyboard move and on keyboard cancel, using thelevel-Nclass WordPress puts on every row. Flat lists (posts, custom post types, non-hierarchical taxonomies) are unaffected. This is not full hierarchical ordering — constraining drags to siblings remains #58, planned for 2.9.0.
- Drag-and-drop silently doing nothing when rows share an order number. Reordering reuses the existing set of
menu_order/term_ordervalues from the dragged rows and re-deals them positionally, which is what keeps other pages of a paginated list untouched. That only works while the values are distinct: where two rows shared a number, sorting and re-dealing the same multiset wrote back exactly the value each row already had — the save reported success and the order never moved, and because the two rows were still tied, which one came first stayed a coin toss on the front end. The reused set is now forced to strictly increase, so every position gets its own value. Reported by @literayz. - Duplicate order numbers were never repaired.
is_already_sequential()— the pre-check that decides whetherrefresh()needs to renumber — comparedCOUNT/MAX/MINonly, so any set with a duplicate and a compensating gap (e.g.{1, 2, 2, 4, 5}: COUNT 5, MAX 5, MIN 1) read as a clean 1..N sequence and was skipped forever. The probe now also comparesCOUNT(DISTINCT …), so duplicates are detected and normalized on the next list-screen render. This is the third correction to this guard; see 2.8.5 for theMIN === 1one. - Rows in a custom post status were excluded from ordering. Every order query hard-coded the five built-in statuses (
publish,pending,draft,private,future). A row in a status registered by another plugin — editorial workflows, "archived", and similar — was therefore left out of both the probe and the renumber, while still rendering in the list table and still being draggable. It kept whatevermenu_orderit had (usually0) as its neighbours were renumbered from1, which is one of the main ways the duplicates above were created. The status set now comes fromget_post_stati( [ 'show_in_admin_all_list' => true ] )— exactly the statuses a list table's "All" view shows, so the rows the plugin numbers match the rows the user can see and drag. On a stock site this resolves to the same five statuses, so the change is purely additive. - Options left behind on uninstall. Only
scporder_installwas deleted, soscporder_notice,scporder_optionsandsimple-rate-timesurvived an uninstall and were silently restored on a later reinstall. All four are now removed (per-site on multisite, as before). Reported by @jamieburchell. - The bundled review notice never ran.
SCPO_Engine::load_dependencies()runs oninitat priority 10 and includesclass-simple-review.php, whose constructor registered anotherinit/10 callback.WP_Hook::apply_filters()iterates a priority's callbacks with a by-valueforeach, so a callback added to the priority already being walked is never seen, andSimple_Review::init()had been dead since the include was moved toinit. It now runs immediately wheninithas already fired, so the class no longer depends on when it is included. Also spotted by @jamieburchell.
Tested up tois now7.1, set in a proper release. The 2.8.6 header was edited in place in the already-published SVN tag, which changed the files wordpress.org serves for a version that was already installed on live sites. Wordfence compares installed files against the copy wordpress.org holds for that version, so every site that had updated to 2.8.6 before the edit started reportingsimple-custom-post-order.phpandreadme.txtas Modified Plugin File. Nothing was compromised — the sole difference was the one header line. Reported by @timwakeling-1 (thread: "Wordfence warnings being generated due to file differences"). Published tags must never be edited after release; compatibility bumps now ship as a version like any other change.- The review notice no longer depends on jQuery. It enqueued jQuery on every admin page and printed an inline jQuery block that was not dependency-ordered, so any site that defers or removes jQuery in wp-admin would have thrown
jQuery is not definedon every screen once the notice started rendering again. It is now vanilla JS, posts to a root-relative same-origin admin-ajax path (matching the reorder endpoints), and delegates its dismiss handler so core's injected.notice-dismissbutton is caught regardless of script order. - The review notice will not reappear all at once on existing sites. A stored
simple-rate-timemore than 90 days in the past can only have come from the period when the notice was not rendering at all — any install that actually saw it pushed the value forward. Re-enabling would have fired the notice on the very next admin page load for all of those at once, so a long-elapsed timer is given a fresh week instead. Values in the future are never touched: a site that clicked Don't show again stays dismissed. - The review notice's message is no longer passed through
sprintf(). The string carries no placeholder, and running a translated string throughsprintf()corrupts any locale whose text contains a literal%. Simple_Reviewnow returns early for users withoutmanage_optionsbefore touching options, and its AJAX handler verifies the capability as well as the nonce.
- "Couldn't update the order — please try again" from the numeric Order column. The column script had none of the resilience the drag-and-drop sorter has had since 2.7.0, and collapsed every possible failure into one alert. Three distinct causes produced that identical message:
- Expired nonce.
check_ajax_referer()answers-1/403, which the script treated as a flat failure with no recovery. A list screen left open past the nonce window — or any site whose security plugin shortensnonce_life— failed every Order-column save until the page was manually reloaded, while drag-and-drop on the same screen kept working. The script now detects the-1body, fetches a fresh nonce fromscpo_refresh_nonce, and retries once, transparently. - Stray output from another plugin. The response was parsed with
res.json(), so a notice or deprecation printed before the JSON made the parse reject — reporting failure for a write that had already succeeded server-side. Parsing is now tolerant, matching the sorter'sJSON.parse-in-try/catch, and recovers the payload from anywhere in the body. - Transient network errors, which now retry once after 800ms before reporting.
- Expired nonce.
- An Order box rendered on rows the user cannot reorder.
scpo_set_positionhas requirededit_poston the specific row since 2.8.3, but the column is registered off the broad reorder capability, so the two could disagree — most often on a custom post type registered with its owncapability_typeand nomap_meta_cap, where even administrators fail the meta-cap check. The result was an editable control that rejected every save.render_order_column()now checkscurrent_user_can( 'edit_post', $post_id )and renders the position as read-only text where the save could not succeed.
- The Order column surfaces the server's own message (
Permission denied.,Invalid item.) and distinguishes an expired session and a connection failure, instead of showing one generic string for every cause. Two new localized strings,expiredandnetwork, joinerrorinscpoOrderCol.
- Stale order values under a persistent object cache. Every order write in the plugin is a raw
$wpdbquery, so none of them passed throughclean_post_cache()/clean_term_cache(). With Redis/Memcached in play, the written rows kept serving their pre-writemenu_order/term_order— showing as stale or duplicated numbers in the admin Order column until the cache was flushed by hand. Two paths were entirely uncovered: the gapless renumber (renumber_rows()) and the sibling shift in new-item placement. Order writes now funnel through a singleinvalidate_order_cache()helper. Reported by @raveendrawpc (#154). - Wrong taxonomy term order on the front end, from the same root cause. Post ordering really is applied in SQL (
ORDER BY menu_order), so it reads the live value — but term ordering is re-sorted in PHP bytaxcmp()on$term->term_order, and thoseWP_Termobjects come from the object cache. A stale entry therefore produced genuinely wrong term order for visitors, not merely wrong-looking numbers in wp-admin. - Term reorders busting the wrong cache entries.
clean_term_cache()interprets a bare ID list as term_taxonomy_ids, not term_ids, so the existingclean_term_cache( $term_id )calls in the drag handler cleared the wrong rows anywhere the two identifiers have diverged. Callers that know the taxonomy now pass it; the drag handler's IDs are resolved per term. - A blind spot in the "already gapless?" skip guard. The pre-check compared
MAXagainstCOUNTonly, so a set like{-1, 2, 3, 4, 5}read as clean and skipped renumbering. It now also requiresMIN === 1(is_already_sequential()).
- New-item placement is a single-row write. With
new_post_positionset to'top'(the default), publishing ranUPDATE … SET menu_order = menu_order + 1across the entire post type — an O(N) write on every publish, and, being raw SQL, one that left all N shifted rows stale in the object cache. Top placement now writesMIN(menu_order) - 1and bottom writesMAX(menu_order) + 1, so both touch exactly one row and the existingclean_post_cache( $post_id )covers them. Ordering is unchanged: gaps and negatives sort correctly, andrefresh()normalizes back to1..Nbefore the list screen renders.menu_order = 0is never written, as that is the handler's "not yet placed" sentinel. - Seeding a newly enabled post type or taxonomy in
sanitize_options()now goes throughrenumber_rows()— one batchedCASEUPDATE plus cache invalidation, instead of one$wpdb->update()per row with none.
- Filter
scpo_cache_flush_group_threshold(int, default500; args$threshold, $table) — the row count above whichinvalidate_order_cache()prefers a singlewp_cache_flush_group()to per-row invalidation. Below the threshold it invalidates row by row, so an ordinary drag save no longer discards every cached post on the site; above it, and only where the backend reportswp_cache_supports( 'flush_group' ), it takes the O(1) group flush. Backends without group-flush support always use the per-row path, so the fix applies everywhere rather than silently doing nothing.
- Very slow admin on sites with many posts. The order-normalization routine
refresh()was hooked onadmin_initwith no screen check, so it ran on every admin page — Dashboard, Plugins, Tools, Settings — not just the sortable list screens. On each run it issued aCOUNT/MAXper enabled type and, whenever a type's order wasn't already gapless, renumbered the whole type oneUPDATEper row. On a site with ~3,000 posts across several enabled types that meant thousands of queries and multi-second TTFB on pages that never display a sortable list (one report measured ~9.5s and 14,000+ queries onplugins.php).refresh()now runs only on the post/taxonomy list screens where the manual order is actually shown (gated by the same_check_load_script_css()check the sorter uses), and off-list admin pages do zero order work. Reported by @crossy.
- The order renumber now writes a gapless
1..Nsequence in a single chunkedCASEUPDATE(batched at 1,000 rows,max_allowed_packet-safe, fully$wpdb->prepare()-bound) via a new internalrenumber_rows()helper, instead of oneUPDATEper row. A dirty list of 2,500 items now normalizes in ~5 queries instead of ~2,500. No change to ordering behavior — every read already tolerates gaps (ORDER BY menu_order/term_order), so the numbering is only tidied right before the ordered list is rendered.
- Per-object authorization on the reorder AJAX endpoints. The three reorder writes (
update_menu_order,update_menu_order_tags,scpo_ajax_set_position) previously gated only on a nonce plus the broad reorder capability (scporder_user_can_reorder(), defaultedit_posts) with no check that the submitted IDs were ones the user may actually edit. Any signed-in user able to reach the endpoints could forge arbitrary post/term IDs and reshuffle their stored order — including posts, pages, or terms outside their own edit permissions (a broken-object-authorization / IDOR pattern; impact bounded to ordering integrity, no content disclosure or editing). Each submitted ID is now validated — the object must exist, belong to an enabled sortable type, and passcurrent_user_can( 'edit_post', $id )(posts) or the taxonomy'smanage_termscapability (terms) — before its order is written; the drag handlers reject the whole batch (403) on any unauthorized ID. No behavior change for the usual reorder users (administrators/editors). Reported by the WordPress.org Plugin Review Team's automated scan.
- Manual post order is no longer ignored on the admin Posts list after using the "All dates" or category dropdown filters. Those filters submit an empty search field (
s=) alongside the search box, and WordPress flags any query with thesvar present as a search (is_search()keys offisset(), not a non-empty value) — so the plugin was skipping its custom order whenever a filter was applied. The order now applies while filtering; genuine searches (a non-empty term in admin,is_search()on the front end) are still left untouched. Props @r-a-y (#153).
- Newly created posts/items no longer default to the bottom of the manual order. The 2.8.0 new-item-placement feature shipped with a
bottomdefault, which silently reversed long-standing behavior — pre-2.8.0, a new post'smenu_order = 0sorted first andrefresh()renumbered it to1, so new items landed at the top. On every site upgrading from before 2.8.0 the option key is absent, and the getter resolved absent →bottom, surfacing as "the latest post is stuck at the bottom of the admin list." The default is back to top; an explicitbottomchoice saved in Settings is still honored. Reported by @ffusion and @deisedesign.
- Reversible previous/next links — new
scpo_reverse_adjacent_postsfilter to flip the direction of the previous/next post-navigation links for manually-ordered posts and custom post types. The 2.7.2 fix (#146) made "previous" the item before the current one in the arranged order and "next" the item after — correct for sequential content, but the opposite of what sites built around WordPress's native chronological convention expect. Returntruefrom the filter to restore the pre-2.7.2 direction without editing your theme's template tags. Reported by @sarahmelyne.
- New-item placement — choose whether newly created posts/pages/items are added to the bottom (default) or top of the manual order (Settings → SCPOrder → Advanced). Props @mplusb (#45).
- Optional "Order" column — an editable position number column on enabled non-hierarchical post-type lists. Type an exact position to move an item — including jumping it across paginated pages — backed by a new
scpo_set_positionAJAX endpoint. Off by default; toggle via Settings and hide/show via Screen Options. (Hierarchical types like Pages get dedicated tree ordering in a later release — #58.) Props @mplusb (#76, #89, #136). - Role-based reordering — restrict drag-and-drop to selected roles in Settings, plus a new
scpo_capabilityfilter for developers (defaultedit_posts). Props @mplusb (#95, #133).
- Quick Edit / Bulk Edit fields (
<input>,<select>) were not clickable with the left mouse button on post/page list screens when the Modern (SortableJS) engine was active. SortableJS excluded the inline-edit rows from dragging viafilter, but its defaultpreventOnFilter: truestill calledpreventDefault()on the mousedown, cancelling native focus and dropdown-open (right-click was unaffected because SortableJS bails on non-left buttons first). SetpreventOnFilter: falseso filtered rows stay undraggable while their fields remain fully interactive. The Classic (jQuery UI) engine was never affected. Reported by @stilografico and @tedmw.
- Previous/next post navigation (
get_previous_post()/get_next_post()and the*_post_linktemplate tags) returned the wrong adjacent post — often reversed — for manually-ordered posts and CPTs. The plugin rewrote only part of WordPress's adjacent-postWHEREclause (leaving thepost_date/IDtiebreaker intact) and used the wrong direction. The clause is now fully rewritten to walkmenu_order, with previous/next matching the manual order. Props @beatricelucaci (#146).
- Post order could be scrambled on MariaDB / MySQL 8 when
menu_orderwas re-normalized after gaps appeared (e.g. after deleting an item). The gap-compacting step relied on a MySQL user-variable ranking (@row_number) whose evaluation order is undefined on those databases. Re-numbering is now done deterministically in PHP. Props @alexgw & @sebastiencyr (#147, #119). get_terms()/wp_get_object_terms()calls that requestorderby=includeare now honored instead of being overridden by the custom term order. Props @glebkema (#67, #66).
- Custom term ordering now applies when any queried taxonomy is sortable (previously only the first taxonomy in a multi-taxonomy query was checked) and keeps the caller's
orderbyas a fallback tiebreaker. Props @goaround (#104).
- Modern SortableJS drag-and-drop engine, now the default — dependency-free vanilla JavaScript, no jQuery UI.
- Touch support — press-and-hold to drag on phones and tablets (taps and vertical scrolling still work).
- Full keyboard accessibility — Tab to a row, Space to grab, arrow keys / Home / End to move, Space to drop, Escape to cancel; every step announced via an ARIA live region.
- Visible save feedback — a "Saving… / Order saved" toast while reordering.
- "Drag & Drop Engine" setting (Settings → SCPOrder) — choose Modern (SortableJS) or Classic (jQuery UI).
- "Drag handle" setting — optionally show a grip icon on row hover; hidden by default and never affects accessibility.
scpo_use_sortablejsfilter — force the engine in code (overrides the setting).scpo_refresh_nonceAJAX endpoint — backs transparent nonce refresh.- Honors the
prefers-reduced-motionuser setting.
- Reordering silently failing to save in some environments — the AJAX request is now always same-origin (root-relative), fixing reverse proxies, load balancers, non-standard ports, and HTTP/HTTPS or domain mismatches.
- Expired security nonce now auto-refreshes and retries the save, so long-open edit screens (or sites with a shortened
nonce_life) keep saving without a reload.
- Rapid successive drags are coalesced into a single request (the final order always wins), with one automatic retry on transient network errors.
- Admin assets are minified, with unminified sources loaded automatically under
SCRIPT_DEBUG;grunt minjsnow builds CSS as well as JS. - The classic jQuery UI Sortable path is retained as an opt-out fallback.
- Unused/dead
assets/taxonomy_order.js.
- Fully backward compatible. Existing settings, hooks (
scp_update_menu_order,scp_update_menu_order_tags,scpo_post_types_args), the global$scporder, and thescporder_optionsstructure are unchanged. Two optional keys were added with safe defaults:engine(sortable) andshow_handle(1).
- Confirmed compatible with WordPress 7.0; maintenance release with no functional changes.
- Settings page rewritten on the WordPress Settings API; security hardening (SQL injection, XSS); PHP 8.4 compatibility; targeted cache invalidation; "Settings" plugin action link.
Older releases (2.5.x and earlier): see the Changelog section of readme.txt.