Tautan OLDER di arsip artikel itu belum pernah tampil sekali pun. Arsipnya baru berisi 14 artikel, sementara query-nya mengambil 24 post per halaman, jadi semua artikel muat di halaman pertama dan navigasi ke halaman berikutnya tidak pernah dirender. Tidak ada pengunjung yang bisa mengklik tautan itu, dan karena itu pula tidak ada yang bisa melihat bahwa di baliknya pagination tersebut rusak dengan tiga cara sekaligus.
Tidak ada yang meminta perbaikannya. Yang diminta klien untuk halaman itu adalah perubahan menu: dropdown topik diganti tautan href sungguhan, lalu ditambah dropdown kedua untuk lokasi. Pekerjaan menu itu naik pada 21 September, dan perbaikan pagination ikut di push yang sama sebagai temuan saya sendiri.
Satu dari tiga kerusakan itu bahkan sudah tertulis di repo. Sejak 13 Agustus, template lain di tema yang sama membawa komentar yang menjelaskan kenapa ia sengaja tidak menyalin pagination ?pn= dari arsip ini: get_query_var('paged', X) mengembalikan 0 pada request halaman, tidak pernah X, sehingga tautan OLDER menyajikan ulang halaman satu. Template arsipnya sendiri membawa baris yang sama sejak pertama kali masuk repo pada 27 Juli sampai commit perbaikan 21 September menghapusnya.
get_query_var('paged', $default) tidak pernah sampai ke default
Versi awal template arsip membaca nomor halamannya seperti ini. Nama variabel dan parameter di semua potongan kode artikel ini saya samarkan.
$paged = max(1, (int) get_query_var('paged', isset($_GET['pn']) ? absint($_GET['pn']) : 1));Baris itu menanyakan paged ke WordPress, dan nilai ?pn= dari URL hanya menjadi default di argumen kedua. Default itu tidak pernah tercapai. WordPress mengisi paged dengan string kosong, sehingga request ?pn=2 merender halaman 1.
Penyebabnya ada di kontrak get_query_var(). Fungsi itu meneruskan default ke WP_Query::get(), yang hanya memakai default kalau kuncinya tidak ada di query_vars atau bernilai null. Kunci paged selalu ada. Saat query utama diurai, fill_query_vars() mengisi sederet query var publik, termasuk paged, dengan string kosong kalau belum terisi, lalu parse_query() mengubahnya menjadi integer. Catatan saya menyebut string kosong dan komentar tadi menyebut 0, dan keduanya benar pada tahap yang berbeda. Bagi baris di atas hasilnya sama saja, karena (int) menjadikan keduanya 0 dan max(1, 0) menghasilkan 1.
Template arsip taksonomi di tema yang sama memakai pola lain, yaitu paging rewrite bawaan WordPress (/page/N/) dengan fallback get_query_var('paged') ?: 1 tanpa argumen default. Operator ?: menangkap string kosong maupun 0 karena keduanya falsy. Menurut catatan saya, idiom get_query_var('paged', $default) hanya ada di template arsip ini.
Perbaikannya berhenti bertanya ke WordPress dan membaca parameternya sendiri, sama seperti template feed berita di tema itu, yang membaca nomor halaman langsung dari $_GET karena tidak punya rewrite sendiri:
$paged = isset($_GET['pn']) ? max(1, absint($_GET['pn'])) : 1;Dua kerusakan lain di tautan OLDER
Versi awal membangun tautan OLDER seperti ini:
<a href="<?php echo esc_url(add_query_arg('pn', $paged + 1, get_permalink())); ?>">OLDER →</a>Tautan itu memanggil get_permalink() sesudah loop dan sebelum wp_reset_postdata(). Tanpa argumen, get_permalink() membaca global post, dan loop di atasnya meninggalkan global post di kartu terakhir. Hasilnya URL sebuah artikel, bukan URL arsip, sehingga OLDER menunjuk ke artikel di kartu terakhir ditambah ?pn=2. Angka 2 itu buah kerusakan pertama: nomor halaman selalu terbaca 1, jadi $paged + 1 selalu 2.
Ini berbeda dari bug global $more yang pernah saya tulis, tempat nama variabel saya ditimpa setup_postdata(). Di sini tidak ada yang ditimpa. get_permalink() bekerja sesuai kontraknya dengan membaca post yang sedang aktif, dan sesudah loop selesai, post yang aktif memang kartu terakhir.
Kerusakan ketiga lebih sederhana. Penyaring arsip itu berupa form berisi input pencarian, select topik native yang langsung submit saat nilainya berubah, dan tombol FILTER, sedangkan tautan pagination membuang kedua parameternya. Halaman berikutnya dari hasil yang sudah disaring akan kehilangan saringannya.
Perbaikan untuk keduanya ada di satu tempat, sebelum loop:
// URL halaman ini sendiri ditambah filter yang aktif: dasar untuk tautan
// pagination dan untuk redirect di bawah. Diambil di sini, sebelum loop
// memindahkan global post. add_query_arg() tidak meng-encode nilai.
$page_url = get_permalink(get_queried_object_id());
$page_args = [];
if ($q !== '') $page_args['q'] = rawurlencode($q);
if ($topic !== '') $page_args['topic'] = $topic;Tautan pagination kini dibangun di atas $page_url, bukan get_permalink(), dan kata kunci pencarian dilewatkan rawurlencode() lebih dulu karena add_query_arg() tidak meng-encode nilai.
Memaksa navigasi yang tidak pernah tampil
Navigasi yang tidak pernah dirender tidak bisa diuji dengan membuka halamannya. Jadi template itu saya render dari CLI dengan hook pre_get_posts yang memaksa 3 post per halaman.
Sebelum perbaikan, request pn=2 menghasilkan tautan OLDER ke /<section>/<last-card-article>/?pn=2 dengan posisi tertulis "1 / 5". Halaman kedua diminta, halaman pertama yang dilayani. Angka 5 cocok dengan hitungannya: 14 artikel dibagi 3 per halaman memberi empat halaman penuh berisi 12 artikel, ditambah satu halaman berisi 2. Sesudah perbaikan, render yang sama menghasilkan halaman berisi 3, 3, 3, 3, dan 2 kartu, dengan kartu pertama yang benar dan filter terbawa di kedua tautan. Jumlahnya 14, sama dengan isi arsip.
Render CLI itu sendiri nyaris menimbulkan masalah baru. Cache halaman WP Rocket di situs ini disimpan di origin sebagai berkas wp-content/cache/wp-rocket/<domain>/<path>/index-https.html dan hanya melayani pengunjung anonim. Salah satu render saya berjalan dengan query string kosong. Advanced-cache WP Rocket menjawabnya dari berkas cache yang sudah ada lalu keluar, dan hanya karena itu halaman paksaan berisi 3 kartu tidak ikut tertulis ke cache halaman. Aturan untuk render CLI sesudahnya: selalu beri query string dan define('DONOTCACHEPAGE', true) sebelum wp-load, lalu periksa jumlah kartu di berkas cache.
Halaman di luar jangkauan dijawab 302
Sebelum push itu, ?pn= yang melewati halaman terakhir merender empty state. Keputusan saya untuk kasus itu: kembalikan saja ke halaman pertama.
if ($paged > 1 && $paged > (int) $query->max_num_pages) {
nocache_headers();
wp_safe_redirect(add_query_arg($page_args, $page_url), 302);
exit;
}Redirect-nya 302, bukan 301, karena nomor halaman itu akan menjadi nyata begitu lebih banyak artikel terbit. Redirect yang tersimpan di cache akan terus membelokkan halaman 2 yang kelak sah, jadi perlakuan edge terhadapnya saya ukur. Cloudflare menjawab BYPASS pada 302 itu selama tiga GET berturut-turut, padahal zona ini meng-cache HTML ber-query-string: probe yang sama pada respons 200 menghasilkan MISS lalu HIT dengan max-age=7200. Zona menghormati header no-cache dari origin pada 302 ini, sehingga redirect basi tidak bisa mengunci halaman 2 kelak. Catatan tambahan 5 Oktober membatasi kesimpulan itu. Respons 404 untuk berkas yang tidak ada di bawah wp-content/plugins tetap disimpan meski no-cache, terlihat pada 14 URL di satu folder, jadi ini bukan aturan umum.
pn=0, pn=abc, pn=1, dan pencarian tanpa hasil tetap menjawab 200. Untuk kasus terakhir, syarat $paged > 1 yang menahannya. Kalau query tidak mengembalikan post sama sekali, WP_Query tidak menghitung found_posts maupun max_num_pages, dan keduanya tetap 0. Komentar di template feed berita mencatatnya untuk halaman yang melewati akhir: halaman 16 memberi 0 post, found_posts 0, dan max_num_pages 0, padahal totalnya 349. Pencarian tanpa hasil di halaman pertama juga punya max_num_pages 0, dan tanpa syarat $paged > 1 halaman itu akan dialihkan ke dirinya sendiri.
500 dari ?topic[]= yang tertangkap sebelum deploy
Sebelum deploy, perubahan ini melewati review adversarial dengan empat lensa. Kode barunya bersih, termasuk rantai parameter pencarian yang lolos round-trip 23 input jahat tanpa double encoding dan tanpa open redirect. Review yang sama menemukan 500 yang sudah hidup di produksi dan terkonfirmasi di origin, yaitu ?topic[]=x. Di PHP 8, sanitize_title() melempar TypeError kalau menerima array, karena nilainya diteruskan ke remove_accents() lalu ke preg_match(). sanitize_text_field() dan absint() tidak melempar, makanya q[] dan pn[] aman.
// is_string() karena sanitize_title() melempar error pada array: ?topic[]=x pernah 500.
$topic = (isset($_GET['topic']) && is_string($_GET['topic'])) ? sanitize_title(wp_unslash($_GET['topic'])) : '';Seluruh tema sudah di-grep, dan tidak ada sanitize_title($_GET...) lain yang tidak dijaga. Guard ini masuk sebagai commit tersendiri supaya bisa dibatalkan sendiri. Catatan saya hanya merekam 500 sebelum perbaikan, sedangkan pengukuran sesudah guard terpasang tidak tercatat.
Default hanya untuk kunci yang tidak ada
Argumen default di get_query_var() hanya bekerja untuk kunci yang benar-benar tidak ada, dan paged tidak pernah tidak ada. Kalau nomor halaman datang dari parameter buatan sendiri, baca langsung dari $_GET. Kalau datang dari WordPress, tangkap 0 dan string kosong dengan ?:, bukan dengan argumen kedua.
Yang lebih patut dicatat adalah cara bug ini bertahan. Tiga kerusakan bersembunyi di balik satu tautan yang tidak pernah dirender, dan satu di antaranya sudah dijelaskan dalam komentar di template sebelah. Memaksa ukuran halaman kecil dari CLI membuat tautan itu bisa diperiksa, tetapi render dari CLI tetap bisa dijawab advanced-cache WP Rocket, seperti render dengan query string kosong tadi. Bukti semacam ini perlu dikerjakan dengan query string, DONOTCACHEPAGE, dan pemeriksaan berkas cache sesudahnya.