Implementasi middleware HTML minifier pada backend Go Fiber sering kali memicu error hydration mismatch pada aplikasi frontend berbasis React atau Vue yang menggunakan Server-Side Rendering (SSR). Masalah ini berakar pada bagaimana minifier memperlakukan karakter spasi, tabulasi, dan newline di antara elemen HTML.

Akar Masalah: Whitespace Text Node dan Siklus Hydration

Saat React atau Vue melakukan hidrasi (hydration), engine mencocokkan Virtual DOM (VDOM) yang dibangun di browser dengan representasi DOM aktual dari HTML yang dikirimkan oleh server. Node teks yang hanya berisi spasi kosong dihitung sebagai #text node independen dalam pohon DOM standar.

Ketika Go Fiber menerapkan minifikasi HTML secara agresif, spasi antar tag inline (seperti <span> atau <a>) sering kali dihapus seluruhnya. Akibatnya, struktur pohon DOM fisik tidak lagi memiliki #text node yang diharapkan oleh VDOM klien. Kondisi ini memicu peringatan runtime umum:

Error: Hydration failed because the initial UI does not match what was rendered on the server.

Perbandingan Diff DOM

Perbedaan struktural antara output template SSR dengan hasil minifikasi standar:

<!-- Output SSR Awal (Ekspektasi VDOM: 3 child nodes: span, #text, span) -->
<div id="root">
  <span>Halo</span> 
  <span>Dunia</span>
</div>

<!-- Output Pasca-Minifikasi Agresif (Aktual: 2 child nodes: span, span) -->
<div id="root"><span>Halo</span><span>Dunia</span></div>

Browser membuang #text node pemisah saat parsing HTML minified. Ketika JavaScript klien dieksekusi, rekonsiliasi gagal karena perbedaan jumlah node anak.

Konfigurasi Preserve Whitespace pada Middleware Fiber

Library minifikasi paling umum di ekosistem Go adalah github.com/tdewolff/minify/v2. Default konfigurasi minifier HTML pada package ini mengeliminasi whitespace redundant. Untuk mencegah kerusakan pohon DOM saat hidrasi, konfigurasi KeepWhitespace harus diaktifkan secara eksplisit.

Berikut implementasi middleware custom di Go Fiber:

package main

import (
	"bytes"
	"regexp"

	"github.com/gofiber/fiber/v2"
	"github.com/tdewolff/minify/v2"
	"github.com/tdewolff/minify/v2/html"
)

func NewHTMLMinifier() fiber.Handler {
	m := minify.New()
	m.Add("text/html", &html.Minifier{
		KeepWhitespace:            true, // Kunci pencegahan hydration mismatch
		KeepComments:              false,
		KeepEndTags:               true,
		KeepDocumentTags:          true,
		KeepQuotes:                true,
	})

	return func(c *fiber.Ctx) error {
		err := c.Next()
		if err != nil {
			return err
		}

		// Terapkan hanya pada response HTML
		cType := string(c.Response().Header.ContentType())
		if !bytes.HasPrefix([]byte(cType), []byte("text/html")) {
			return nil
		}

		body := c.Response().Body()
		minified, err := m.Bytes("text/html", body)
		if err != nil {
			// Fallback: kembalikan body asli jika minifikasi gagal
			return nil
		}

		c.Response().SetBody(minified)
		return nil
	}
}

Strategi Isolasi Hydration Payload

Selain tag teks inline, aplikasi SSR modern menyertakan payload hidrasi berupa script JSON seperti <script id="__NEXT_DATA__" type="application/json">. Minifikasi yang ceroboh berisiko merusak payload data ini jika regex atau parser salah membaca delimitasi script.

Gunakan langkah-langkah berikut untuk isolasi rute dan payload:

  • Lewati minifikasi pada runtime dynamic injection: Pastikan script dengan type="application/json" diabaikan oleh parser minifier dengan mendaftarkan parser khusus jika diperlukan.
  • Bypass middleware via path condition: Hindari minifikasi pada stream API internal atau rute SSR yang menggunakan streaming render (seperti React 18 renderToPipeableStream) karena memproses seluruh buffer di memori meniadakan keuntungan streaming.

Verifikasi Integritas Whitespace Menggunakan app.Test

Pastikan middleware tidak merusak spasi kritis dengan menulis integration test menggunakan utilitas pengujian internal Fiber app.Test.

package main

import (
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/gofiber/fiber/v2"
)

func TestHTMLMinifierPreservesInlineWhitespace(t *testing.T) {
	app := fiber.New()
	app.Use(NewHTMLMinifier())

	app.Get("/ssr", func(c *fiber.Ctx) error {
		c.Set("Content-Type", "text/html; charset=utf-8")
		// Format HTML yang merepresentasikan batas hidrasi komponen frontend
		htmlPayload := `<div id="app"><span>First</span> <span>Second</span></div>`
		return c.SendString(htmlPayload)
	})

	req := httptest.NewRequest(http.MethodGet, "/ssr", nil)
	resp, err := app.Test(req, -1)
	if err != nil {
		t.Fatalf("Request error: %v", err)
	}

	body, _ := io.ReadAll(resp.Body)
	result := string(body)

	// Validasi spasi kritis antara dua span tetap ada
	expectedNode := `<span>First</span> <span>Second</span>`
	if !strings.Contains(result, expectedNode) {
		t.Errorf("Whitespace hilang pasca-minifikasi.\nExpected: %s\nActual: %s", expectedNode, result)
	}
}

Uji coba unit di atas mengonfirmasi bahwa spasi tunggal dipertahankan tanpa membiarkan newline atau indentation berlebih tetap ada dalam dokumen response.