Menurut pihak klien, mereka sempat mencari tempat di Studio untuk memperbarui judul dan deskripsi homepage sendiri, dan tidak menemukannya. Jadi keduanya saya pindahkan ke dokumen pengaturan homepage di Sanity Studio, dan hanya keduanya. Objek SEO bersama yang lebih lengkap sengaja tidak ikut dibuka, karena di dalamnya ada noindex dan canonical, dan satu salah klik di sana sudah cukup untuk mencabut homepage dari indeks.
Situsnya berjalan di Next.js 16, dan generateMetadata homepage tidak menunggu query begitu saja. Dia mengambil meta dari sebuah fungsi yang menyimpan satu nilai di tingkat modul, mengembalikan nilai itu seketika, lalu menyegarkannya lewat after() sesudah respons terkirim. Akibatnya, suntingan di Studio baru tampil mulai request sesudah request yang memicu penyegaran. Rancangan ini lahir dari masalah lain, meta homepage yang terdorong ke dalam body pada request pertama sesudah webhook, dan itu cerita tersendiri. Disederhanakan dan dengan nama yang diganti, bentuknya sebelum ada penjaga kira-kira begini:
// versi disederhanakan, nama-nama diganti
import { after } from "next/server";
let cached: SiteMeta | null = null;
// reload() mengambil dokumen pengaturan lewat helper fetch CMS,
// lalu menyimpan meta-nya ke cached
export async function getSiteMeta(): Promise<SiteMeta> {
if (!cached) return reload();
after(reload);
return cached;
}Yang belum dijawab rancangan ini adalah satu pertanyaan sederhana: request siapa saja yang boleh mengisi cached?
Render draft yang akan mengisi nilai publik
Celahnya ketahuan saat review atas perubahan meta homepage itu pada 14 September 2026, sebelum rilis. Temuan review-nya begini: after() mempertahankan status draft milik request, sehingga render dari Presentation tool atau render draft lainnya akan menyimpan judul setengah ketik yang belum terbit ke nilai bersama, lalu menyajikannya ke Googlebot.
Render draft untuk / bisa datang dari tiga arah: Presentation tool, refresh dari VisualEditing, atau editor yang browsernya membawa cookie draft. Di proyek ini Presentation tool menyalakan draft mode lewat sebuah rute API, dan root layout merender <VisualEditing /> setiap kali draft mode aktif. Begitu draft mode menyala, helper fetch CMS-nya berganti perspektif:
// helper fetch CMS, disederhanakan, nama-nama diganti
const isDraft = (await draftMode()).isEnabled;
return client.fetch(query, params, {
next: {
// Skip the baseline TTL in draft mode so previews stay fresh.
revalidate: isDraft ? 0 : BASE_TTL,
},
perspective: isDraft ? "previewDrafts" : "published",
});Untuk preview, itu perilaku yang benar. Editor memang perlu melihat suntingan yang belum terbit, dan TTL dasar dilewati supaya preview tetap segar. Masalahnya ada pada tempat hasil fetch itu disimpan. Kalau render draft ikut memanggil getSiteMeta() di proses yang sudah punya nilai, after(reload) terjadwal dari request draft, dan reload() berjalan sesudah respons dengan draft mode yang masih aktif. Fetch di dalamnya memilih previewDrafts dan revalidate: 0, hasilnya menimpa cached, dan pengunjung berikutnya, termasuk Googlebot, akan menerima judul yang masih setengah diketik.
Kalau ditarik keluar dari kasus ini, kalimatnya jadi umum: cache selebar proses yang disegarkan dari after() akan menyimpan suntingan Studio yang belum terbit dan menyajikannya ke publik, kecuali request draft dibuat melewatinya.
after() membawa store milik request asalnya
Inti masalahnya ada di after() sendiri. Callback-nya berjalan sesudah respons terkirim, tapi dengan store milik request yang menjadwalkannya, jadi draftMode() di dalamnya masih menjawab aktif. Catatan yang saya tulis waktu itu menunjuk dua tempat: after-context.js membungkus callback dengan bindSnapshot sehingga AsyncLocalStorage milik request ikut terbawa, dan draft-mode.js tidak punya penjaga untuk phase === "after".
Perubahan ini di-commit di atas Next 16.2.4, lalu proyeknya naik ke 16.3.6 pada 28 September. Potongan di bawah berasal dari 16.3.6, jadi bukan berkas yang persis sama dengan saat kejadian, tapi di versi itu mekanismenya terbaca jelas:
// next/dist/server/after/after-context.js, Next 16.3.6, dipangkas
workUnitStore.phase = 'after'; // saat request ditutup
// Bind the callback to the current execution context (i.e. preserve all currently available ALS-es).
const wrappedCallback = (0, _asynclocalstorage.bindSnapshot)(/* ... */);
// next/dist/server/request/draft-mode.js, Next 16.3.6, dipangkas
case 'request':
return createOrGetCachedDraftMode(workUnitStore.draftMode, workStore);
get isEnabled() {
if (this._provider !== null) {
return this._provider.isEnabled;
}
return false;
}Saat request ditutup, phase milik work unit store diganti menjadi 'after'. Tapi callback-nya sudah diikat ke konteks eksekusi pada saat after() dipanggil, dan komentar Next menyebut semua AsyncLocalStorage yang tersedia ikut dipertahankan, jadi status draft bukan satu-satunya yang terbawa. Untuk work unit bertipe request, draftMode() langsung mengembalikan status draft milik store itu, dan getter isEnabled cuma meneruskan nilai dari provider-nya.
Kata phase hanya muncul sekali di draft-mode.js, yaitu di pemeriksaan phase === 'after' di dalam fungsi yang dipanggil dari enable() dan disable() saja. Jadi pemeriksaan fase memang ada, tapi jalur baca lewat isEnabled tidak pernah melewatinya. Pesan error di pemeriksaan itu malah menyatakannya terang-terangan:
The enabled status of `draftMode()` can be read inside `after()` but you cannot enable or disable `draftMode()`.Artinya, di 16.3.6 membaca status draft di dalam after() adalah perilaku yang diizinkan, bukan kelalaian yang menunggu ditambal. Penjaganya harus ada di kode aplikasi.
Penjaga di baris pertama
Perbaikannya membuat request draft melewati memori itu sepenuhnya. Penjaganya jadi baris pertama fungsi, mendahului pembacaan cached dan penjadwalan after(reload), dan request draft langsung mengambil dokumen pengaturan lalu mengembalikan meta-nya. Kalau pemanggilan draftMode() sendiri melempar error, request dianggap bukan draft. Masih dengan nama yang diganti:
// versi disederhanakan, nama-nama diganti
async function isPreviewRequest(): Promise<boolean> {
try {
return (await draftMode()).isEnabled;
} catch {
return false;
}
}
export async function getSiteMeta(): Promise<SiteMeta> {
// A draft render reads unpublished edits, and an after() callback keeps the
// request's draft state. Letting it touch the shared value would serve a
// half-typed Studio title to the next visitor, Googlebot included. Editors
// previewing do not need the in-head guarantee, so they bypass it entirely.
if (await isPreviewRequest()) return (await getSettingsDoc()).meta;
if (!cached) return reload();
after(reload);
return cached;
}Komentar di atas penjaga itu juga mencatat keputusannya: editor yang sedang preview tidak butuh jaminan meta di dalam head, jadi mereka melewati nilai bersama sepenuhnya. Konsekuensinya, setiap render draft menunggu fetch-nya sendiri, dan untuk preview itu harga yang wajar. Di sisi pemanggil, generateMetadata homepage membawa komentar yang meminta siapa pun membaca modul ini lebih dulu sebelum menggantinya dengan await biasa.
Dua tes, dan bukti bahwa keduanya bisa gagal
Tesnya memock draftMode dari next/headers dengan isEnabled yang bisa disetel per tes, dan memock after dari next/server sehingga callback-nya hanya ditampung di sebuah array. Dari array itu, tes bisa melihat apakah sebuah request menjadwalkan penyegaran atau tidak. Dua tes barunya, disederhanakan:
// versi disederhanakan, nama-nama diganti
vi.mock("next/headers", () => ({
draftMode: vi.fn(async () => ({ isEnabled: draftEnabled })),
}));
vi.mock("next/server", () => ({
after: vi.fn((callback: () => unknown) => {
afterCallbacks.push(callback);
}),
}));
it("never lets a draft preview reach what public visitors are served", async () => {
mockedDoc.mockResolvedValueOnce(docWith("Published"));
await getSiteMeta();
draftEnabled = true;
mockedDoc.mockResolvedValueOnce(docWith("Half-typed draft"));
expect((await getSiteMeta()).title).toBe("Half-typed draft");
expect(afterCallbacks).toHaveLength(0);
draftEnabled = false;
expect((await getSiteMeta()).title).toBe("Published");
});
it("does not seed a cold process from a draft preview", async () => {
draftEnabled = true;
mockedDoc.mockResolvedValueOnce(docWith("Draft"));
await getSiteMeta();
draftEnabled = false;
mockedDoc.mockResolvedValueOnce(docWith("Published"));
expect((await getSiteMeta()).title).toBe("Published");
});Tes pertama memastikan request draft menerima judul draft tanpa menjadwalkan satu pun callback after(), lalu request publik sesudahnya tetap menerima "Published". Tes kedua menjaga jalan masuk yang lain, yaitu proses yang belum punya nilai sama sekali: request draft di proses itu tidak boleh menjadi pengisi pertama cached, dan request publik berikutnya harus menerima "Published".
Uji mutasi membuktikan kedua tes itu gagal kalau penjaganya dibuang. Langkah inilah yang membuat tesnya layak dipercaya, karena tes yang tetap hijau tanpa penjaga sebenarnya tidak sedang menguji penjaganya.
Yang sudah terbukti, dan batasnya
Perubahan ini masuk produksi pada 14 September 2026 dan terverifikasi live. Pesan commit-nya menyatakan render draft mode melewati nilai bersama, sehingga suntingan yang belum terbit tidak pernah sampai ke pengunjung publik atau crawler, dan riwayat git modul itu hanya berisi commit tersebut. Penjaga dan memorinya masuk bersamaan, jadi versi tanpa penjaga tidak pernah ada di git. Temuannya sendiri datang sebelum rilis.
Pemeriksaan di produksi sesudahnya menunjukkan 12 dari 12 request homepage membawa semua meta di dalam head. Hari itu juga pihak klien menerbitkan judul dan deskripsi baru dari Studio, dan uji ujung ke ujung pada 15 September menunjukkan HTML produksi menyajikan keduanya di dalam head pada 6 dari 6 request, sebagai Googlebot dan sebagai sebuah crawler SEO.
Tapi semua pemeriksaan produksi itu menguji jalur publik. Penjaga draft-nya sendiri dibuktikan lewat dua tes unit dan uji mutasinya, dan tidak ada catatan uji preview draft di produksi. Skenario kebocorannya juga tetap berupa pengandaian: judul setengah ketik itu akan tersimpan dan tersaji kalau penjaganya tidak ada, dan karena temuannya datang sebelum rilis, tidak ada kejadian nyata yang bisa ditunjuk.
Aturan untuk setiap memo yang diisi saat request
Aturan yang saya tarik dari sini berlaku lebih luas dari homepage ini. Setiap memo tingkat modul atau tingkat proses yang diisi fetch saat request harus memeriksa draft mode lebih dulu, dan untuk request draft dia tidak membaca nilai bersama, tidak menulisnya, dan tidak menjadwalkan after(). Larangan ketiga yang paling licin, karena after() kelihatan seperti pekerjaan yang sudah lepas dari request, padahal dia masih membawa status draft request itu.
Aturan pengujiannya ikut serta: tambahkan tes yang memock draft mode aktif dan memastikan nilai publik tidak tersentuh, lalu buang penjaganya, dan tes itu harus gagal. Kalau tidak gagal, tesnya belum menguji apa yang dia kira sedang dia uji.