Akar Masalah Hydration Mismatch pada Atribut ARIA

Error Hydration attribute mismatch (id/aria-describedby) terjadi ketika arsitektur hybrid (Go Fiber SSR yang menghidrasi komponen React atau Vue) merender atribut DOM di server dengan nilai yang tidak identik dengan hasil eksekusi Virtual DOM di browser. Elemen form aksesibel membutuhkan relasi ID yang ketat antara <label for="...">, <input id="...">, dan <span aria-describedby="...">.

Kesalahan umum backend Go adalah menggunakan generator acak seperti uuid.New() atau bilangan acak dari package crypto/rand per pemanggilan render. Saat server mengirimkan markup dengan ID acak, framework frontend di sisi client mengeksekusi logika hydration-nya sendiri (misalnya useId() pada React) dan mendeteksi ketidaksinkronan nilai atribut. Akibatnya, frontend runtime membuang server-rendered DOM dan melakukan de-optimasi dengan re-render penuh di client.

Arsitektur Deterministic ID Generator per Request

Solusi yang benar adalah membuat ID deterministik yang bertumpu pada sequence counter terisolasi per lifecycle HTTP request. Counter harus selalu dimulai dari angka awal (misalnya 0 atau 1) untuk setiap request masuk, sehingga komponen form yang dirender dalam urutan yang sama akan selalu menghasilkan output string ID yang identik.

Penggunaan variable global dilarang karena dua alasan: race condition antar-goroutine dan state leak antar-request concurrent. Go Fiber menyediakan c.Locals sebagai storage key-value yang terikat langsung pada pointer *fiber.Ctx request aktif.

Implementasi Middleware dan Context ID di Go Fiber

Berikut implementasi counter deterministik yang aman dari race condition dengan mengisolasi state generator ke dalam c.Locals:

package main

import (
	"fmt"
	"sync/atomic"

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

// DeterministicID mengelola sequence identifier per request
type DeterministicID struct {
	prefix  string
	counter uint64
}

func NewDeterministicID(prefix string) *DeterministicID {
	return &DeterministicID{
		prefix:  prefix,
		counter: 0,
	}
}

// Next menghasilkan ID terurut deterministik
func (d *DeterministicID) Next(suffix ...string) string {
	val := atomic.AddUint64(&d.counter, 1)
	if len(suffix) > 0 {
		return fmt.Sprintf("%s-%d-%s", d.prefix, val, suffix[0])
	}
	return fmt.Sprintf("%s-%d", d.prefix, val)
}

func IDMiddleware() fiber.Handler {
	return func(c *fiber.Ctx) error {
		// Inisialisasi generator baru per request lifecycle
		c.Locals("idGen", NewDeterministicID("app"))
		return c.Next()
	}
}
Catatan: Meskipun objek generator diisolasi per request, penggunaan sync/atomic tetap disarankan jika pipeline template engine Anda mengevaluasi blok komponen secara asinkron atau konkuren.

Integrasi Template Helper dan Payload SSR

Agar frontend dan backend memiliki referensi ID yang sama, seed prefix atau ID terurut tersebut dapat dipassing ke template engine Go Fiber (seperti html/template) atau diinjeksikan langsung ke root mounting point client.

app := fiber.New()
app.Use(IDMiddleware())

app.Get("/form", func(c *fiber.Ctx) error {
	idGen := c.Locals("idGen").(*DeterministicID)

	// Generate deterministic IDs untuk field ARIA
	inputID := idGen.Next("field")
	errorID := fmt.Sprintf("%s-error", inputID)

	// Kirim ID ke template HTML Fiber
	return c.Type("html").SendString(fmt.Sprintf(`
		<div class="form-group">
			<label for="%s">Alamat Email</label>
			<input id="%s" type="email" aria-describedby="%s" />
			<span id="%s" class="text-error">Format email tidak valid</span>
		</div>
	`, inputID, inputID, errorID, errorID))
})

Dengan pola di atas, pemanggilan pertama halaman /form akan menghasilkan id="app-1-field" dan aria-describedby="app-1-field-error" secara konsisten, terlepas dari berapa juta request yang masuk secara paralel.

Verifikasi Concurrency dengan app.Test

Verifikasi dilakukan menggunakan app.Test untuk memastikan tidak ada collision atau drift pada counter ID saat dieksekusi oleh request concurrent.

package main

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

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

func TestDeterministicIDConcurrency(t *testing.T) {
	app := fiber.New()
	app.Use(IDMiddleware())

	app.Get("/check", func(c *fiber.Ctx) error {
		idGen := c.Locals("idGen").(*DeterministicID)
		id := idGen.Next()
		return c.SendString(id)
	})

	const concurrentRequests = 50
	var wg sync.WaitGroup
	wg.Add(concurrentRequests)

	for i := 0; i < concurrentRequests; i++ {
		go func() {
			defer wg.Done()
			req := httptest.NewRequest(http.MethodGet, "/check", nil)
			resp, err := app.Test(req, -1)
			if err != nil {
				t.Errorf("Request failed: %v", err)
				return
			}

			body, _ := io.ReadAll(resp.Body)
			bodyStr := strings.TrimSpace(string(body))

			// Setiap request wajib mendapatkan ID pertama: app-1
			if bodyStr != "app-1" {
				t.Errorf("Expected 'app-1', got '%s'", bodyStr)
			}
		}()
	}

	wg.Wait()
}

Pitfall dan Trade-off

  • Branching Conditional Rendering: Jika server dan client memiliki branching rendering yang berbeda (misalnya otentikasi hanya dicek di client), urutan eksekusi Next() akan bergeser, memicu mismatch. Pastikan tree rendering SSR identik dengan initial hydration tree client.
  • Sub-components Rendering: Jika menggunakan multi-goroutine di dalam satu handler untuk merender parsial HTML secara paralel, urutan pemanggilan Next() tidak deterministik. Gunakan sub-prefix spesifik seperti idGen.SubNamespace("modal") untuk menjaga isolasi urutan.