readysite / docs / frontend.md
6.2 KB
frontend.md

Frontend Layer

Island architecture for interactive JavaScript components within HTMX-driven pages.

Design Principles

Principle Rationale
Progressive enhancement Works without JavaScript, islands add interactivity
Framework agnostic You control render/unmount logic in your entry point
Explicit over magic Framework code stays in your codebase, not ours
HTMX integration Components re-mount after HTMX swaps

Quick Start

1. Add Frontend Option

import (
    "github.com/readysite/readysite/pkg/application"
    "github.com/readysite/readysite/pkg/frontend"
    "github.com/readysite/readysite/pkg/frontend/esbuild"
)

func main() {
    application.Serve(views,
        frontend.WithBundler(&esbuild.Config{
            Entry:   "components/index.ts",
            Include: []string{"components"},
        }),
        application.WithController(controllers.Home()),
    )
}

2. Add Template Functions

<!-- views/layouts/main.html -->
<!DOCTYPE html>
<html>
<head>
    {{frontend_script}}
</head>
<body>
    {{block "content" .}}{{end}}
</body>
</html>

3. Create Components

components/Counter.tsx:

import { useState } from 'react';

export function Counter({ initial = 0 }) {
    const [count, setCount] = useState(initial);
    return (
        <button className="btn btn-primary" onClick={() => setCount(c => c + 1)}>
            Count: {count}
        </button>
    );
}

components/index.ts:

import * as React from 'react';
import * as ReactDOM from 'react-dom/client';

// Render function - mounts a component to an element
export function render(el: HTMLElement, Component: React.ComponentType<any>, props: any) {
    const root = ReactDOM.createRoot(el);
    root.render(React.createElement(Component, props));
    (el as any)._root = root;
}

// Unmount function - unmounts a component from an element
export function unmount(el: HTMLElement) {
    if ((el as any)._root) {
        (el as any)._root.unmount();
        delete (el as any)._root;
    }
}

// Export components
export * from './Counter';

4. Install Dependencies

package.json:

{
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0"
  }
}

No build scripts needed - the Go server bundles components automatically using esbuild.

5. Use in Templates

{{define "content"}}
<div class="container">
    <h1>Interactive Counter</h1>
    {{render "Counter" (dict "initial" 5)}}
</div>
{{end}}

How It Works

  1. {{frontend_script}} outputs:

    • __renderAll() function that finds all [data-component] elements
    • HTMX integration for swap handling
    • Script tag loading your components bundle
    • Call to __renderAll() after load
  2. Your entry point exports:

    • render(el, Component, props) - mount logic
    • unmount(el) - unmount logic
    • Named component exports (e.g., Counter)
  3. The bundler wraps your exports:

    • Assigns render to window.__render
    • Assigns unmount to window.__unmount
    • Assigns components to window.ComponentName
  4. On page load: __renderAll() mounts all components

  5. On HTMX swap: Unmounts replaced components, renders new ones

Config Options

&esbuild.Config{
    // Entry point file (required)
    Entry: "components/index.ts",

    // Directories to watch for HMR (optional)
    Include: []string{"components", "hooks", "utils"},
}

Template Functions

Function Purpose
{{frontend_script}} Injects orchestration + script tags
{{render "Name" props}} Renders component island with props

render

Creates an island container:

<div data-component="Counter" data-props='{"initial":5}'>
    <div class="skeleton h-32 w-full"></div>
</div>

Props are JSON-serialized. The skeleton placeholder displays while loading.

Directory Structure

project/
├── components/
│   ├── index.ts        # Entry point with render/unmount
│   ├── Counter.tsx
│   └── PostEditor.tsx
├── views/
│   └── static/
│       └── scripts/
│           └── gen/    # Built output (gitignore this)
│               └── components.js
├── package.json
└── main.go

Other Frameworks

Solid.js

// components/index.ts
import { render as solidRender } from 'solid-js/web';
import { Counter } from './Counter';

export function render(el: HTMLElement, Component: any, props: any) {
    (el as any)._dispose = solidRender(() => Component(props), el);
}

export function unmount(el: HTMLElement) {
    if ((el as any)._dispose) (el as any)._dispose();
}

export { Counter };

Svelte

// components/index.ts
import Counter from './Counter.svelte';

export function render(el: HTMLElement, Component: any, props: any) {
    (el as any)._component = new Component({ target: el, props });
}

export function unmount(el: HTMLElement) {
    if ((el as any)._component) (el as any)._component.$destroy();
}

export { Counter };

Development Mode

In development (ENV != production):

  • File watching is enabled
  • Changes trigger automatic rebuild
  • HMR notifies browser to reload

Best Practices

When to Use Islands

Use Case Approach
Simple interactions HTMX (no island needed)
Rich text editing React island
Data visualization React island
File trees, drag-drop React island
Forms, buttons HTMX
Search, filters HTMX with debounce

Component Guidelines

  1. Keep islands small - Most of your page should be server-rendered
  2. Props are serializable - Pass simple data, not functions
  3. Use HTMX for data fetching - Islands don't need to own data

Troubleshooting

Component not mounting

  1. Check window.ComponentName is defined in browser console
  2. Check window.__render exists
  3. Verify {{frontend_script}} is in <head>
  4. Check browser console for [frontend] Component not found warnings

HTMX swap not re-mounting

  1. Verify unmount cleans up properly
  2. Check HTMX events are firing (htmx:beforeSwap, htmx:afterSwap)
← Back