Akar Masalah: Non-Determinisme pada Transformasi AST
Hydration mismatch terjadi saat representasi DOM hasil render server (SSR) tidak identik dengan Virtual DOM yang dibangun runtime client saat fase hidrasi. Pada pipeline modern yang mengadopsi metaprogramming atau compile-time macro (seperti Babel transformer atau SWC visitor), desinkronisasi ini sering kali bersumber dari ekspansi AST yang non-deterministik.
Ketika bundler memisahkan target build antara server (Node.js/Edge runtime) dan client (browser bundle), proses transformasi AST dijalankan dalam instance atau thread worker terpisah. Ketidakcocokan muncul apabila macro compile-time melanggar prinsip macro hygiene—sebagaimana distandardisasi pada sistem macro modern seperti Rhombus atau Scheme. Pelanggaran umum meliputi:
- Pembuatan identifier dinamis berbasis memori compiler: Menggunakan counter global per-proses,
Math.random(), atau timestamp saat menyusun node AST. - Asimetri evaluasi branch compile-time: Macro melakukan inlining ekspresi yang berbeda untuk server dan client karena flag environment diinjeksi tidak seragam sebelum visitor berjalan.
- Perbedaan urutan traversal modul: Bergantung pada urutan import yang diresolusi bundler untuk server bundle vs client bundle.
Transformasi AST: Masalah vs Solusi Higienis
Contoh berikut mendemonstrasikan Babel transformer yang menghasilkan ID unik untuk komponen form. Kesalahan ini langsung memicu error hydration mismatch pada React atau Vue.
Transformer Non-Deterministik (Bermasalah)
let idCounter = 0;
module.exports = function({ types: t }) {
return {
name: "unhygienic-id-macro",
visitor: {
CallExpression(path) {
if (path.node.callee.name === "$autoId") {
// Gagal deterministik: Urutan eksekusi file server vs client tidak dijamin sama.
// idCounter berbeda antara server bundle dan client bundle.
const generatedId = `field-${++idCounter}`;
path.replaceWith(t.stringLiteral(generatedId));
}
}
}
};
};
Transformer di atas menyimpan state mutabel di memori modul build. Saat server bundle mengompilasi Header.tsx terlebih dahulu, elemen mendapatkan field-1. Namun, jika client pipeline memproses file dalam worker terpisah atau urutan berbeda karena split-chunking, ID yang dihasilkan bergeser menjadi field-12. SSR markup memuat id="field-1", sedangkan browser Virtual DOM menganggap nilai seharusnya id="field-12".
Transformer Higienis dan Deterministik (Solusi)
Penerapan macro higienis mewajibkan ekspansi deterministik. ID harus diturunkan dari metadata AST yang stabil: path file relatif dan koordinat posisi leksikal (posisi baris dan kolom node).
const crypto = require("crypto");
const pathLib = require("path");
module.exports = function({ types: t }) {
return {
name: "hygienic-id-macro",
visitor: {
CallExpression(path, state) {
if (path.node.callee.name === "$autoId") {
const filename = state.filename || "fallback";
// Normalisasi path agar OS-agnostik (posix format)
const normalizedPath = pathLib.relative(state.cwd || process.cwd(), filename).replace(/\\/g, "/");
const loc = path.node.loc?.start;
const line = loc ? loc.line : 0;
const column = loc ? loc.column : 0;
// Derivasi identifier stabil dari koordinat AST
const rawSource = `${normalizedPath}:${line}:${column}`;
const hash = crypto.createHash("sha256").update(rawSource).digest("hex").substring(0, 8);
const deterministicId = `id-${hash}`;
path.replaceWith(t.stringLiteral(deterministicId));
}
}
}
};
};
Mengapa solusi ini deterministik: Koordinat leksikal AST pada source code bersifat immutable terhadap target output. Baik server compiler maupun client compiler akan selalu menghasilkan string identik untuk call-site yang sama.
Verifikasi Hydration Parity Menggunakan Snapshot Test
Untuk mendeteksi desinkronisasi sebelum build masuk staging, verifikasi parity output macro antara SSR engine dan client hydration harness dapat dilakukan via assertion test:
import { describe, it, expect } from "vitest";
import React from "react";
import { renderToString } from "react-dom/server";
import { hydrateRoot } from "react-dom/client";
// Komponen uji yang memanfaatkan macro
import { FormField } from "./FormField";
describe("Macro SSR Hydration Parity", () => {
it("menghasilkan markup SSR dan hydration state yang identik", () => {
// 1. Render Server
const ssrHtml = renderToString(<FormField label="Email" />);
// 2. Setup Container DOM Client
const container = document.createElement("div");
container.innerHTML = ssrHtml;
document.body.appendChild(container);
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// 3. Hydrate
hydrateRoot(container, <FormField label="Email" />);
// 4. Assert: Tidak boleh ada hydration warning dari framework runtime
const hydrationErrors = consoleErrorSpy.mock.calls.filter(([msg]) =>
typeof msg === "string" && msg.includes("did not match")
);
expect(hydrationErrors).toHaveLength(0);
consoleErrorSpy.mockRestore();
document.body.removeChild(container);
});
});
Konfigurasi Linter untuk Mencegah Macro Impure
Mencegah regresi memerlukan static analysis yang memvalidasi bahwa ekspresi macro tidak mengonsumsi input non-deterministik atau mengekspos variabel global compiler. Buat aturan ESLint lokal (atau AST validation pass) untuk memeriksa target macro:
// eslint-rules/enforce-macro-purity.js
module.exports = {
meta: {
type: "problem",
docs: {
description: "Cegah pemanggilan macro dengan argumen runtime non-deterministik"
}
},
create(context) {
return {
CallExpression(node) {
if (node.callee.name === "$autoId") {
if (node.arguments.length > 0) {
context.report({
node,
message: "Macro $autoId tidak boleh menerima parameter runtime untuk menjamin determinisme SSR."
});
}
}
}
};
}
};
Daftarkan rule tersebut ke konfigurasi build:
// .eslintrc.js
module.exports = {
plugins: ["local-rules"],
rules: {
"local-rules/enforce-macro-purity": "error"
}
};
Trade-off dan Langkah Berikutnya
Mengunci determinisme AST pada posisi file memiliki batasan: jika terdapat layer transpiler lain sebelum macro berjalan yang mengubah line/column source mapping, hash koordinat dapat terganggu. Pastikan AST macro transformer dijalankan pada posisi paling awal (pre-step) dari pipeline kompilasi sebelum transformasi syntax kompleks lainnya dieksekusi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!