D
P
0

Web Development

Fitur “Sudah Jadi” tapi Diam-diam Mati: Kontrak JS↔REST yang Melenceng

27 Juni 2026·4 menit baca
Fitur “Sudah Jadi” tapi Diam-diam Mati: Kontrak JS↔REST yang Melenceng

Waktu saya audit sebuah multi-listing site yang saya bantu rawat, saya menemukan empat fitur yang di atas kertas "sudah jadi" — tombolnya ada, kodenya rapi, dan sudah lolos code review berbulan-bulan lalu. Masalahnya: keempatnya diam-diam mati. Tombol diklik, ada efek hover, kursor berubah, lalu... tidak terjadi apa-apa. Tidak ada toast, tidak ada perubahan DOM, tidak ada navigasi. Dan yang paling jahat: tidak ada satu pun error di console.

Gejalanya selalu sama. Misalnya tombol "Simpan ke favorit". Saya klik, kursor sibuk sebentar, lalu kembali normal seakan-akan berhasil. Tapi daftar favorit tidak berubah. Ketika saya buka DevTools, console-nya bersih. Inilah potongan JS yang menjalankannya:

async function saveListing(id) {
  const res = await fetch(`/wp-json/app/v1/listing/${id}/favorite`);
  const data = await res.json();
 
  if (!data.listing_id) return; // diam-diam keluar di sini
 
  showToast(`Tersimpan: ${data.title}`);
  document.querySelector('.favorite-count').textContent = data.count;
}

Tidak ada yang salah secara sintaks. Tidak ada exception. Fungsi ini menjalankan tugasnya dengan sempurna — yaitu tidak melakukan apa-apa.

Kenapa ini terjadi

Akar masalahnya satu kata: drift. Sisi server sudah berevolusi, sisi front-end belum ikut. Kontrak antara JS dan REST yang dulu cocok, perlahan melenceng tanpa ada yang sadar.

Di keempat fitur, polanya beda-beda tapi penyakitnya sama:

  • Field JSON yang di-rename. Endpoint dulu mengembalikan listing_id, sekarang id. JS masih membaca data.listing_id, dapat undefined, lalu return.
  • Bentuk response yang berubah. Dulu server membalas array langsung, sekarang dibungkus jadi { data: [...] }. JS me-loop response yang sekarang adalah objek, dapat nol iterasi.
  • Selector yang dipindah/di-rename. Sebuah refactor markup mengganti .favorite-count jadi .listing__fav-count. querySelector mengembalikan null, dan tulisan ke .textContent jatuh ke objek yang tidak ada.
  • Endpoint yang sekarang wajib param. Server menambah parameter wajib (misal context) yang sebelumnya opsional. Tanpa param itu, response berubah bentuk, dan field yang ditunggu JS hilang.

Yang bikin ini licin: di setiap kasus, JavaScript membaca undefined lalu early-return — sebuah no-op yang sopan. Tidak ada yang throw. fetch tetap dapat HTTP 200. res.json() tetap mem-parse JSON yang valid. Hanya saja isinya bukan bentuk yang diharapkan JS. Jadi tombol kelihatan hidup, padahal sudah mati.

Dan inilah kenapa code review lolos. Saat di-review, sisi JS terlihat benar secara internal — pemanggilan fetch rapi, guard if (!data.x) return justru terlihat seperti defensive coding yang baik. Sisi PHP juga terlihat benar — endpoint mengembalikan JSON yang valid sesuai bentuk barunya. Kedua sisi konsisten dengan dirinya sendiri. Yang tidak terlihat di atas kertas adalah drift di antara keduanya. Reviewer membaca dua file terpisah, bukan satu percakapan utuh antara browser dan server.

Perbaikannya

Saya berhenti percaya pada "kodenya kelihatan benar". Saya verifikasi setiap pemanggilan JS↔REST dan JS↔DOM end-to-end di browser sungguhan.

Caranya sederhana tapi disiplin: klik tombolnya, lalu buka tab Network. Saya lihat request yang benar-benar dikirim, lalu saya baca response asli — bukan response yang saya bayangkan. Begitu saya lihat payload-nya, drift langsung kelihatan: id, bukan listing_id. Lalu saya konfirmasi side effect-nya benar-benar terjadi — toast muncul, .textContent berubah, navigasi jalan — bukan sekadar "kodenya seharusnya begitu".

Setelah field aslinya ketahuan, perbaikannya tinggal menyelaraskan kontraknya:

async function saveListing(id) {
  const res = await fetch(`/wp-json/app/v1/listing/${id}/favorite?context=view`);
  const data = await res.json();
 
  if (!data.id) {
    console.warn('[favorite] field "id" hilang dari response', data);
    return;
  }
 
  showToast(`Tersimpan: ${data.title}`);
  const counter = document.querySelector('.listing__fav-count');
  if (!counter) {
    console.warn('[favorite] selector .listing__fav-count tidak ditemukan');
    return;
  }
  counter.textContent = data.count;
}

Perhatikan tambahan console.warn-nya. Itu bukan hiasan. Itu guard runtime tipis yang membuat drift jadi berisik. Lain kali server me-rename field atau seseorang memindahkan selector, kode ini tidak diam-diam mati — ia berteriak di console dengan menyebut persis apa yang hilang. No-op yang sopan adalah musuh; saya ingin kegagalan yang vokal.

Empat fitur, empat penyelarasan kontrak yang sama jenisnya, semuanya ketemu hanya dengan satu hal: mengklik tombolnya di browser sungguhan dan menonton Network.

Pelajaran

Code review yang lolos tidak bisa menangkap contract drift antara client dan server. Reviewer melihat dua sisi yang masing-masing benar, dan menyimpulkan keseluruhannya benar — padahal yang patah justru sambungan di tengah yang tidak ada di file mana pun.

Satu-satunya bukti bahwa JS dan REST masih sepakat adalah satu klik nyata di browser sungguhan: kirim request asli, baca response asli, konfirmasi side effect asli. Fitur tidak "jadi" karena kodenya kelihatan benar. Fitur "jadi" kalau tombolnya benar-benar melakukan sesuatu saat ditekan. Dan kalau Anda menambahkan guard yang berisik saat ekspektasi meleset, drift berikutnya akan ketahuan dalam hitungan detik — bukan berbulan-bulan kemudian saat seorang auditor iseng menekan setiap tombol.