Akar Masalah: Intervensi DOM Sebelum Hidrasi Selesai
Pada aplikasi berbasis Inertia.js dengan Server-Side Rendering (SSR), server mengirimkan markup HTML awal yang berisi elemen form statis, misalnya <input name="email" value="">. Saat browser menerima HTML tersebut dan melakukan first paint, ekstensi password manager (seperti 1Password, Bitwarden) atau fitur native autofill browser langsung menginjeksi kredensial ke atribut value elemen DOM input target.
Masalah timbul karena injeksi nilai ini terjadi sebelum atau bersamaan dengan inisialisasi runtime JavaScript (client-side hydration Vue 3 atau React). Hook useForm dari Inertia.js menginisialisasi reactive state internal berdasarkan nilai default yang didefinisikan di kode JavaScript, biasanya string kosong:
const form = useForm({
email: '',
password: '',
});Injeksi autofill langsung memanipulasi properti DOM HTMLInputElement.value tanpa memicu event sintetis input atau change standar. Akibatnya, terjadi divergensi state: elemen input secara visual terisi kredensial di layar pengguna, namun reactive state form.email dan form.password tetap bernilai string kosong.
Dampak Teknis: Silent Validation Failure
Desinkronisasi ini menghasilkan kegagalan validasi yang membingungkan pengguna (UI glitch dan silent failure). Alur kegagalan sistematis meliputi:
- Validasi Gagal di Server: Ketika form disubmit via
form.post('/login'), payload yang dikirimkan menggunakan nilai reactive state, bukan representasi DOM visual. Request HTTP mengirimkan field kosong, menghasilkan HTTP response422 Unprocessable Entity. - State Overwrite saat Re-render: Jika komponen melakukan re-render reaktif, virtual DOM engine dapat memaksakan binding
:value="form.email"ke DOM riil, menghapus teks yang telah diisikan oleh autofill secara tiba-tiba di depan mata pengguna. - Hydration Mismatch Warning: Pada mode development, framework dapat melempar warning hydration mismatch karena DOM atribut aktual berbeda dengan Virtual DOM snapshot.
Pola Sinkronisasi Pasca-Hidrasi (Lifecycle Hook)
Solusi deterministik untuk mengatasi masalah ini adalah menyinkronkan nilai DOM ke reactive state segera setelah hidrasi selesai via lifecycle hook (onMounted di Vue 3 atau useEffect di React). Pendekatan ini membaca langsung nilai aktual dari DOM node dan memperbarui state useForm.
Implementasi di Vue 3 + Inertia
<script setup>
import { ref, onMounted } from 'vue';
import { useForm } from '@inertiajs/vue3';
const formRef = ref(null);
const form = useForm({
email: '',
password: '',
});
const syncAutofillValues = () => {
if (!formRef.value) return;
const inputs = formRef.value.querySelectorAll('input[name]');
inputs.forEach((input) => {
const fieldName = input.getAttribute('name');
if (fieldName in form && input.value && form[fieldName] === '') {
form[fieldName] = input.value;
}
});
};
onMounted(() => {
// Eksekusi segera pasca hidrasi
syncAutofillValues();
// Polling singkat untuk menangani password manager yang lambat menginjeksi
const timer = setTimeout(syncAutofillValues, 500);
return () => clearTimeout(timer);
});
const submit = () => {
syncAutofillValues(); // Safeguard sebelum payload dikirim
form.post('/login');
};
</script>
<template>
<form ref="formRef" @submit.prevent="submit">
<div>
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
autocomplete="username"
v-model="form.email"
/>
</div>
<div>
<label for="password">Password</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
v-model="form.password"
/>
</div>
<button type="submit" :disabled="form.processing">Log In</button>
</form>
</template>Menangani Event Autofill Sintetis via CSS Animation
Browser berbasis WebKit/Blink menerapkan pseudo-class :-webkit-autofill saat autofill aktif. Memanfaatkan CSS keyframe animation memungkinkan aplikasi menangkap momen tepat ketika autofill disuntikkan tanpa mengandalkan timer interval.
/* Tambahkan ke CSS global atau scoped */
@keyframes onAutoFillStart {
from { /**/ }
to { /**/ }
}
input:-webkit-autofill {
animation-name: onAutoFillStart;
animation-duration: 0.001s;
}Dengarkan event animationstart di komponen target untuk menyinkronkan state secara real-time:
const handleAnimationStart = (event) => {
if (event.animationName === 'onAutoFillStart') {
const target = event.target;
const fieldName = target.name;
if (fieldName in form) {
form[fieldName] = target.value;
}
}
};
// Pasang event listener pada form container di onMounted
onMounted(() => {
formRef.value?.addEventListener('animationstart', handleAnimationStart);
});Praktik Aksesibilitas dan Konfigurasi Atribut
Jangan pernah mematikan atribut autocomplete (misal: autocomplete="off") hanya untuk menghindari bug hidrasi. Praktik tersebut merusak aksesibilitas web dan mengganggu password manager pengguna. Ikuti standar berikut:
- Tentukan atribut
namedanidyang jelas dan konsisten di setiap elemen input. - Gunakan token standar WHATWG untuk atribut
autocomplete(misalnya:username,current-password,new-password,one-time-code). - Pertahankan binding data dua arah (
v-modelpada Vue atau propertivalue+onChangepada React), tetapi selalu sertakan mekanisme fallback pembacaan nilai DOM sebelum pemanggilan mutasiform.post()atauform.put().
Unit Testing: Verifikasi Sinkronisasi State
Gunakan Vitest dan Vue Test Utils untuk memastikan sinkronisasi state DOM ke useForm berfungsi ketika input diisi tanpa memicu event input biasa.
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi } from 'vitest';
import LoginForm from '@/Pages/Auth/LoginForm.vue';
// Mock Inertia useForm
vi.mock('@inertiajs/vue3', () => ({
useForm: (initialData) => ({
...initialData,
post: vi.fn(),
processing: false,
}),
}));
describe('LoginForm Autofill Sync', () => {
it('menyinkronkan nilai DOM native ke reactive state saat mounted', async () => {
const wrapper = mount(LoginForm, {
attachTo: document.body,
});
const emailInput = wrapper.find('input[name="email"]').element;
const passwordInput = wrapper.find('input[name="password"]').element;
// Simulasikan injeksi autofill langsung ke DOM tanpa event input
emailInput.value = '[email protected]';
passwordInput.value = 'secret123';
// Picu siklus sinkronisasi
await wrapper.vm.syncAutofillValues();
expect(wrapper.vm.form.email).toBe('[email protected]');
expect(wrapper.vm.form.password).toBe('secret123');
wrapper.unmount();
});
});
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!