diff --git a/.gitignore b/.gitignore index 03bf458..58f6fb7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,10 @@ *.out .claude/settings.local.json +*.test +examples/counter/counter +examples/clock/clock +examples/hooks/hooks +examples/forms/forms +examples/chat/chat +examples/alpine/alpine +web/-o diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ebef39c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +Lint: +``` +go fmt ./... +go vet ./... +``` + +Test: +``` +go fix ./... +go test ./... +``` diff --git a/README.md b/README.md index 5e09038..9f29742 100644 --- a/README.md +++ b/README.md @@ -2,561 +2,781 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/jfyne/live#.svg)](https://pkg.go.dev/github.com/jfyne/live#) -Real-time user experiences with server-rendered HTML in Go. Inspired by and -borrowing from Phoenix LiveViews. - -Live is intended as a replacement for React, Vue, Angular etc. You can write -an interactive web app just using Go and its templates. - -![](https://github.com/jfyne/live-examples/blob/main/chat.gif) - -The structures provided in this package are compatible with `net/http`, so will play -nicely with middleware and other frameworks. +Real-time interactive UI components with server-rendered HTML in Go. Build dynamic web applications using only Go and HTML templates. + +Live v2 introduces a pure **islands architecture** where independent, interactive components (islands) share a single transport connection. Each island maintains isolated state and lifecycle, enabling granular interactivity without full-page reloads. + +## Version 2 Breaking Changes + +**This is version 2 of Live**, a complete rewrite with breaking changes from v1. v2 adopts a pure islands architecture, replacing the full-page LiveView pattern. + +### Major Changes from v1 + +- **Islands-only architecture**: No more full-page `Handler` - use `Island` components instead +- **New API**: `Island`, `IslandEngine`, `Session`, and `Transport` replace v1's `Handler`, `Engine`, and `Socket` +- **Custom elements**: Client uses `` custom elements instead of document-wide initialization +- **Transport abstraction**: WebSocket, SSE, or polling (v1 was WebSocket-only) +- **State isolation**: Each island instance has completely isolated state +- **Multiple islands per page**: Share a single connection with message routing + +If you're using v1, see the [Migration Guide](#migration-from-v1) below. + +## Table of Contents + +- [Community](#community) +- [Getting Started](#getting-started) + - [Installation](#installation) + - [Quick Example](#quick-example) +- [Core Concepts](#core-concepts) + - [Islands](#islands) + - [Props and State](#props-and-state) + - [Event Handling](#event-handling) + - [Transport Layer](#transport-layer) +- [Server-Side API](#server-side-api) + - [Creating an Island](#creating-an-island) + - [Registering Islands](#registering-islands) + - [Setting Up the Engine](#setting-up-the-engine) + - [Transport Endpoints](#transport-endpoints) +- [Client-Side Usage](#client-side-usage) + - [The `` Element](#the-live-island-element) + - [Passing Props](#passing-props) + - [Event Attributes](#event-attributes) +- [Examples](#examples) +- [Migration from v1](#migration-from-v1) +- [Advanced Topics](#advanced-topics) +- [API Reference](#api-reference) ## Community -For bugs please use github issues. If you have a question about design or adding features, I -am happy to chat about it in the discussions tab. +For bugs, please use GitHub issues. For questions about design or adding features, use the discussions tab. -Discord server is [here](https://discord.gg/TuMNaXJMUG). +Discord server: [https://discord.gg/TuMNaXJMUG](https://discord.gg/TuMNaXJMUG) ## Getting Started -### Examples - -- [Alpinejs](https://github.com/jfyne/live/tree/master/examples/alpine) -- [Buttons](https://github.com/jfyne/live/tree/master/examples/buttons) -- [Chat](https://github.com/jfyne/live/tree/master/examples/chat) -- [Clock](https://github.com/jfyne/live/tree/master/examples/clock) -- [Clocks](https://github.com/jfyne/live/tree/master/examples/clocks) -- [Cluster](https://github.com/jfyne/live/tree/master/examples/cluster) -- [Error](https://github.com/jfyne/live/tree/master/examples/error) -- [Pagination](https://github.com/jfyne/live/tree/master/examples/pagination) -- [Todo](https://github.com/jfyne/live/tree/master/examples/todo) +### Installation -#### Live components - -- [World Clocks](https://github.com/jfyne/live/tree/master/examples/clocks) - -### Install - -``` -go get github.com/jfyne/live +```bash +go get github.com/jfyne/live@v2 ``` -See the [examples](https://github.com/jfyne/live/tree/master/examples) for usage. +**Note**: Make sure to use the `v2` tag or the `v2` branch to get the islands architecture. + +### Quick Example -### First handler +Here's a simple counter island to demonstrate the v2 API: -Here is an example demonstrating how we would make a simple thermostat. Live is compatible -with `net/http`. +**Server (Go):** -[embedmd]:# (example_test.go) ```go -package live +package main import ( - "bytes" - "context" - "html/template" - "io" - "net/http" + "bytes" + "context" + "html/template" + "io" + "log" + "net/http" + "time" + + "github.com/jfyne/live" ) -// Model of our thermostat. -type ThermoModel struct { - C float32 +// CounterState holds the state for a counter island +type CounterState struct { + Count int } -// Helper function to get the model from the socket data. -func NewThermoModel(s *Socket) *ThermoModel { - m, ok := s.Assigns().(*ThermoModel) - // If we haven't already initialised set up. - if !ok { - m = &ThermoModel{ - C: 19.5, - } - } - return m +// NewCounterIsland creates a counter island definition +func NewCounterIsland() (*live.Island, error) { + island, err := live.NewIsland( + "counter", + live.WithMount(func(ctx context.Context, props live.Props, children string) (any, error) { + // Initialize state from props + initialValue := props.Int("initial-value") + return &CounterState{Count: initialValue}, nil + }), + live.WithRender(func(ctx context.Context, rc *live.IslandRenderContext) (io.Reader, error) { + state := rc.State.(*CounterState) + + tmpl := ` +
+
{{.Count}}
+ + +
+ ` + + t, _ := template.New("counter").Parse(tmpl) + var buf bytes.Buffer + t.Execute(&buf, state) + return &buf, nil + }), + ) + if err != nil { + return nil, err + } + + // Register event handlers + island.HandleEvent("inc", func(ctx context.Context, state any, params live.Params) (any, error) { + s := state.(*CounterState) + s.Count++ + return s, nil + }) + + island.HandleEvent("dec", func(ctx context.Context, state any, params live.Params) (any, error) { + s := state.(*CounterState) + s.Count-- + return s, nil + }) + + return island, nil } -// thermoMount initialises the thermostat state. Data returned in the mount function will -// automatically be assigned to the socket. -func thermoMount(ctx context.Context, s *Socket) (any, error) { - return NewThermoModel(s), nil +func main() { + // Register the island + live.RegisterIsland("counter", NewCounterIsland) + + ctx := context.Background() + stateStore := live.NewMemoryIslandStateStore(ctx, 1*time.Minute) + engine := live.NewIslandEngine(ctx, live.DefaultRegistry(), stateStore) + + // Set up WebSocket endpoint + wsConfig := live.DefaultTransportConfig() + http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + transport, _ := live.UpgradeWebSocket(r.Context(), w, r, wsConfig) + sessionID := live.SessionID("session-123") + session := live.NewSession(r.Context(), sessionID, transport) + + engine.AddSession(session) + defer engine.DeleteSession(sessionID) + + // Handle events + for event := range transport.Events() { + if event.T == "subscribe" { + params, _ := event.Params() + islandType := params.String("type") + props := make(live.Props) + for k, v := range params { + if k != "type" && k != "id" { + props[k] = v + } + } + engine.MountIsland(sessionID, live.IslandID(event.Island), islandType, props) + } else { + engine.RouteEvent(sessionID, event) + } + } + }) + + // Serve HTML + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(indexHTML)) + }) + + log.Println("Server running on :8080") + http.ListenAndServe(":8080", nil) } -// tempUp on the temp up event, increase the thermostat temperature by .1 C. An EventHandler function -// is called with the original request context of the socket, the socket itself containing the current -// state and and params that came from the event. Params contain query string parameters and any -// `live-value-` bindings. -func tempUp(ctx context.Context, s *Socket, p Params) (any, error) { - model := NewThermoModel(s) - model.C += 0.1 - return model, nil -} +const indexHTML = ` + +Counter + +

Live v2 Counter

+ +
0
+
+ + +` +``` -// tempDown on the temp down event, decrease the thermostat temperature by .1 C. -func tempDown(ctx context.Context, s *Socket, p Params) (any, error) { - model := NewThermoModel(s) - model.C -= 0.1 - return model, nil -} +**Client (HTML):** -// Example shows a simple temperature control using the -// "live-click" event. -func Example() { - - // Setup the handler. - h := NewHandler() - - // Mount function is called on initial HTTP load and then initial web - // socket connection. This should be used to create the initial state, - // the socket Connected func will be true if the mount call is on a web - // socket connection. - h.MountHandler = thermoMount - - // Provide a render function. Here we are doing it manually, but there is a - // provided WithTemplateRenderer which can be used to work with `html/template` - h.RenderHandler = func(ctx context.Context, data *RenderContext) (io.Reader, error) { - tmpl, err := template.New("thermo").Parse(` -
{{.Assigns.C}}
- - - - - `) - if err != nil { - return nil, err - } - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, err - } - return &buf, nil - } - - // This handles the `live-click="temp-up"` button. First we load the model from - // the socket, increment the temperature, and then return the new state of the - // model. Live will now calculate the diff between the last time it rendered and now, - // produce a set of diffs and push them to the browser to update. - h.HandleEvent("temp-up", tempUp) - - // This handles the `live-click="temp-down"` button. - h.HandleEvent("temp-down", tempDown) - - http.Handle("/thermostat", NewHttpHandler(context.Background(), h)) - - // This serves the JS needed to make live work. - http.Handle("/live.js", Javascript{}) - - http.ListenAndServe(":8080", nil) -} +```html + + + + Counter Example + + +

Live v2 Counter

+ + + +
+
0
+ + +
+
+ + + +
+
5
+ + +
+
+ + + + + ``` -Notice the `script` tag. Live's javascript is embedded within the library for ease of use, and -is required to be included for it to work. You can also use the companion -[npm package](https://www.npmjs.com/package/@jfyne/live) to add to any existing web app build -pipeline. +The content inside `` is the initial server-rendered HTML, replaced by the server once the island mounts. + +## Core Concepts -### Live components +### Islands -Live can also render components. These are an easy way to encapsulate event logic and make it repeatable across a page. -The [components examples](https://github.com/jfyne/live/tree/master/examples/components) show how to create -components. Those are then used in the [world clocks example](https://github.com/jfyne/live/tree/master/examples/clocks). +An **island** is an independent, interactive component with its own: +- **State**: Isolated state that persists across events +- **Lifecycle**: Mount, render, unmount handlers +- **Events**: User interactions (clicks, form submissions, etc.) +- **Props**: Initial configuration passed from HTML attributes + +Islands are defined once via `NewIsland()` and registered globally. Multiple instances can be created from a single island definition. + +### Props and State + +**Props** are passed to an island from its HTML attributes and are read-only: + +```html + +``` -[embedmd]:# (page/example_test.go) ```go -package page +live.WithMount(func(ctx context.Context, props live.Props, children string) (any, error) { + initialValue := props.Int("initial-value") // Reads data-initial-value + return &CounterState{Count: initialValue}, nil +}) +``` -import ( - "context" - "io" - "net/http" +**State** is the internal data of an island instance, updated by event handlers: - "github.com/jfyne/live" -) +```go +island.HandleEvent("inc", func(ctx context.Context, state any, params live.Params) (any, error) { + s := state.(*CounterState) + s.Count++ // Mutate state + return s, nil // Return updated state +}) +``` -// NewGreeter creates a new greeter component. -func NewGreeter(ID string, h *live.Handler, s *live.Socket, name string) (*Component, error) { - return NewComponent( - ID, - h, - s, - WithMount(func(ctx context.Context, c *Component) error { - c.State = name - return nil - }), - WithRender(func(w io.Writer, c *Component) error { - // Render the greeter, here we are including the script just to make this toy example work. - return HTML(` -
Hello {{.}}
- - `, c).Render(w) - }), - ) -} +After an event handler returns, Live re-renders the island with the new state and sends a patch to the client. + +### Event Handling -func Example() { - h := live.NewHandler( - WithComponentMount(func(ctx context.Context, h *live.Handler, s *live.Socket) (*Component, error) { - return NewGreeter("hello-id", h, s, "World!") - }), - WithComponentRenderer(), - ) - - http.Handle("/", live.NewHttpHandler(context.Background(), h)) - http.Handle("/live.js", live.Javascript{}) - http.ListenAndServe(":8080", nil) +Islands handle user interactions via event handlers registered with `HandleEvent()`: + +```go +island.HandleEvent("inc", func(ctx context.Context, state any, params live.Params) (any, error) { + // Handle the event, update state + return newState, nil +}) +``` + +Events are triggered by `live-*` attributes in the HTML: + +```html + +
...
+ +``` + +When a user clicks the button, the client sends an event with `t: "inc"` to the server, which routes it to the correct island instance. + +### Transport Layer + +Live v2 abstracts the transport layer, supporting multiple protocols: + +- **WebSocket** (default): Bidirectional, low-latency +- **SSE (Server-Sent Events)**: Server-to-client streaming with HTTP POST for client events +- **Polling** (future): Fallback for restricted environments + +The client automatically negotiates the best available transport. All islands on a page share a single connection. + +## Server-Side API + +### Creating an Island + +Use `NewIsland()` with functional options to define an island: + +```go +func NewCounterIsland() (*live.Island, error) { + island, err := live.NewIsland( + "counter", // Island type name + live.WithMount(mountHandler), + live.WithRender(renderHandler), + live.WithUnmount(unmountHandler), + ) + if err != nil { + return nil, err + } + + // Register event handlers + island.HandleEvent("inc", incHandler) + island.HandleEvent("dec", decHandler) + + return island, nil } ``` -## Navigation +**Handler signatures:** -Live provides functionality to use the browsers pushState API to update its query parameters. This can be done from -both the client side and the server side. +```go +// Called when island is mounted (created) +func mountHandler(ctx context.Context, props live.Props, children string) (any, error) { + // Return initial state + return &MyState{}, nil +} -### Client side +// Called to render the island's current state +func renderHandler(ctx context.Context, rc *live.IslandRenderContext) (io.Reader, error) { + state := rc.State.(*MyState) + // Render state to HTML + return reader, nil +} -The `live-patch` handler should be placed on an `a` tag element as it reads the `href` attribute in order to apply -the URL patch. +// Called when island is unmounted (destroyed) +func unmountHandler(ctx context.Context, state any) error { + // Cleanup resources + return nil +} -```html -Next page +// Called when an event occurs +func eventHandler(ctx context.Context, state any, params live.Params) (any, error) { + // Update and return state + return newState, nil +} ``` -Clicking on this tag will result in the browser URL being updated, and then an event sent to the backend which will -trigger the handler's `HandleParams` callback. With the query string being available in the params map of the handler. +### Registering Islands + +Register islands with the global registry so they can be instantiated by the engine: ```go -h.HandleParams(func(s *live.Socket, p live.Params) (any, error) { - ... - page := p.Int("page") - ... -}) +func main() { + err := live.RegisterIsland("counter", NewCounterIsland) + if err != nil { + log.Fatal(err) + } +} ``` -### Server side +The first argument is the island type name (must match the `type` attribute in HTML). -Using the Socket's `PatchURL` func the serverside can make the client update the browsers URL, which will then trigger the `HandleParams` func. +### Setting Up the Engine -### Redirect +Create an `IslandEngine` to manage sessions and island instances: -The server can also trigger a redirect if the Socket's `Redirect` func is called. This will simulate an HTTP redirect -using `window.location.replace`. +```go +ctx := context.Background() -## Features +// Create a state store for island state persistence +stateStore := live.NewMemoryIslandStateStore(ctx, 1*time.Minute) -### Click Events +// Create the engine +registry := live.DefaultRegistry() +engine := live.NewIslandEngine(ctx, registry, stateStore) +defer engine.Close() +``` -- [ ] live-capture-click -- [x] live-click -- [x] live-value-* +The engine: +- Manages sessions (one per connected client) +- Routes events to the correct island instance +- Coordinates rendering and patching -The `live-click` binding is used to send click events to the server. +### Transport Endpoints -```html -
+Set up HTTP endpoints for WebSocket or SSE transports. + +**WebSocket endpoint:** + +```go +http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + wsConfig := live.DefaultTransportConfig() + transport, err := live.UpgradeWebSocket(r.Context(), w, r, wsConfig) + if err != nil { + http.Error(w, "WebSocket upgrade failed", http.StatusBadRequest) + return + } + + sessionID := live.SessionID("unique-session-id") + session := live.NewSession(r.Context(), sessionID, transport) + engine.AddSession(session) + defer engine.DeleteSession(sessionID) + + // Process events + for event := range transport.Events() { + if event.T == "subscribe" && event.Island != "" { + // Island mount request + params, _ := event.Params() + islandType := params.String("type") + props := extractProps(params) + + engine.MountIsland( + sessionID, + live.IslandID(event.Island), + islandType, + props, + ) + } else { + // Regular event + engine.RouteEvent(sessionID, event) + } + } +}) ``` -See the [buttons example](https://github.com/jfyne/live/tree/master/examples/buttons) for usage. +**SSE endpoints:** -### Focus / Blur Events +```go +sseConfig := live.DefaultTransportConfig() +sseFactory := live.NewSSETransportFactory(sseConfig) + +// SSE stream endpoint +http.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) { + transport, err := sseFactory.Upgrade(r.Context(), w, r) + if err != nil { + http.Error(w, "SSE upgrade failed", http.StatusBadRequest) + return + } + + sessionID := live.SessionID("unique-session-id") + session := live.NewSession(r.Context(), sessionID, transport) + engine.AddSession(session) + defer engine.DeleteSession(sessionID) + + // Process events (same as WebSocket) + for event := range transport.Events() { + // ... handle events + } +}) + +// SSE POST endpoint for client events +http.HandleFunc("/sse/post", sseFactory.HandlePost) +``` -- [x] live-window-focus -- [x] live-window-blur -- [x] live-focus -- [x] live-blur +## Client-Side Usage -Focus and blur events may be bound to DOM elements that emit such events, -using the `live-blur`, and `live-focus` bindings, for example: +### The `` Element + +Use the `` custom element to define interactive islands in your HTML: ```html - + + +
Count: 0
+
``` -### Key Events +**Required attributes:** +- `type`: Island type name (must match registered island) +- `id`: Unique identifier for this island instance + +**Optional attributes:** +- `data-*`: Props passed to the island's mount handler -- [x] live-window-keyup -- [x] live-window-keydown -- [x] live-keyup -- [x] live-keydown -- [x] live-key +### Passing Props -The onkeydown, and onkeyup events are supported via the `live-keydown`, and `live-keyup` -bindings. Each binding supports a `live-key` attribute, which triggers the event for the -specific key press. If no `live-key` is provided, the event is triggered for any key press. -When pushed, the value sent to the server will contain the "key" that was pressed. +Props are extracted from `data-*` attributes: -See the [buttons example](https://github.com/jfyne/live/tree/master/examples/buttons) for usage. +```html + + +``` -### Form Events +Access in Go: -- [ ] live-auto-recover -- [ ] live-trigger-action -- [ ] live-disable-with -- [ ] live-feedback-for -- [x] live-submit -- [x] live-change +```go +live.WithMount(func(ctx context.Context, props live.Props, children string) (any, error) { + userID := props.String("user-id") // "123" + editable := props.Bool("editable") // true + return &ProfileState{UserID: userID, Editable: editable}, nil +}) +``` -To handle form changes and submissions, use the `live-change` and `live-submit` events. In general, -it is preferred to handle input changes at the form level, where all form fields are passed to the -handler's event handler given any single input change. For example, to handle real-time form validation -and saving, your template would use both `live-change` and `live-submit` bindings. +### Event Attributes -See the [form example](https://github.com/jfyne/live/tree/master/examples/todo) for usage. +Wire up event handlers with `live-*` attributes: -### Rate Limiting +**Click events:** +```html + + +``` -- [x] live-debounce -- [ ] live-throttle +**Form events:** +```html +
+ + +
-All events can be rate-limited on the client by using the `live-debounce` and `live-throttle` bindings, -with the following behavior: + +``` -`live-debounce` accepts either an integer timeout value (in milliseconds), -or "blur". When an integer is provided, emitting the event is delayed by the specified milliseconds. When -"blur" is provided, emitting the event is delayed until the field is blurred by the user. Debouncing is typically -used for input elements. +**Key events:** +```html + +``` -`live-throttle` accepts an integer timeout value to throttle the event in milliseconds. Unlike debounce, -throttle will immediately emit the event, then rate limit it at once per provided timeout. Throttling is -typically used to rate limit clicks, mouse and keyboard actions. +**Focus events:** +```html + +``` -### Dom Patching +**Rate limiting:** +```html + + +``` -- [x] live-update +**DOM patching control:** +```html +
+
+``` -A container can be marked with `live-update`, allowing the DOM patch operations -to avoid updating or removing portions of the view, or to append or prepend the -updates rather than replacing the existing contents. This is useful for client-side -interop with existing libraries that do their own DOM operations. The following -`live-update` values are supported: +## Examples -- `replace` - replaces the element with the contents -- `ignore` - ignores updates to the DOM regardless of new content changes -- `append` - append the new DOM contents instead of replacing -- `prepend` - prepend the new DOM contents instead of replacing +Complete examples are in the `examples/` directory: -When using `live-update` If using "append" or "prepend", a DOM ID must be set -for each child. +- **[Counter](examples/counter/)**: Basic counter demonstrating islands, props, and events -See the [chat example](https://github.com/jfyne/live/tree/master/examples/chat) for usage. +More examples coming soon: +- Chat application with multiple islands +- Form validation and submission +- Real-time collaboration +- Server-pushed updates -### JS Interop +## Migration from v1 -- [x] live-hook +v2 is a complete rewrite. Here's how to migrate from v1 to v2: -### Hooks +### Conceptual Changes -Hooks take the following form. They allow additional javascript to hook into the live lifecycle. -These should be used to implement custom behavior and bind additional events which are not supported -out of the box. +| v1 Concept | v2 Equivalent | Notes | +|------------|---------------|-------| +| Full-page `Handler` | `Island` components | Islands replace full-page LiveViews | +| `Handler.MountHandler` | `Island.Mount` | Called per island instance, not per page | +| `Handler.RenderHandler` | `Island.Render` | Renders island HTML, not full page | +| `Socket` | `Session` | Transport-agnostic connection | +| `Engine` (1:1 with Handler) | `IslandEngine` | Manages multiple islands | +| `page.Component` | `Island` | Islands replace the component abstraction | +| Document-wide events | Island-scoped events | Events wired within island boundary | +| WebSocket only | `Transport` interface | WebSocket, SSE, or polling | -[embedmd]:# (web/src/interop.ts) -```ts -/** - * Hooks supplied for interop. - */ -export interface Hooks { - [id: string]: Hook; -} +### Code Migration -/** - * A hook for running external JS. - */ -export interface Hook { - /** - * The element has been added to the DOM and its server - * LiveHandler has finished mounting - */ - mounted?: () => void; - - /** - * The element is about to be updated in the DOM. - * Note: any call here must be synchronous as the operation - * cannot be deferred or cancelled. - */ - beforeUpdate?: () => void; - - /** - * The element has been updated in the DOM by the server - */ - updated?: () => void; - - /** - * The element is about to be removed from the DOM. - * Note: any call here must be synchronous as the operation - * cannot be deferred or cancelled. - */ - beforeDestroy?: () => void; - - /** - * The element has been removed from the page, either by - * a parent update, or by the parent being removed entirely - */ - destroyed?: () => void; - - /** - * The element's parent LiveHandler has disconnected from - * the server - */ - disconnected?: () => void; - - /** - * The element's parent LiveHandler has reconnected to the - * server - */ - reconnected?: () => void; -} +**v1 full-page handler:** -/** - * The DOM management interface. This allows external JS libraries to - * interop with Live. - */ -export interface DOM { - /** - * The fromEl and toEl DOM nodes are passed to the function - * just before the DOM patch operations occurs in Live. This - * allows external libraries to (re)initialize DOM elements - * or copy attributes as necessary as Live performs its own - * patch operations. The update operation cannot be cancelled - * or deferred, and the return value is ignored. - */ - onBeforeElUpdated?: (fromEl: Element, toEl: Element) => void; +```go +// v1 +h := live.NewHandler() +h.MountHandler = func(ctx context.Context, s *live.Socket) (any, error) { + return &PageModel{}, nil } +h.RenderHandler = func(ctx context.Context, rc *live.RenderContext) (io.Reader, error) { + // Render entire page + return renderPage(rc.Assigns), nil +} +h.HandleEvent("click", clickHandler) +http.Handle("/live", live.NewHttpHandler(ctx, h)) ``` -In scope when these functions are called: - -- `el` - attribute referencing the bound DOM node, -- `pushEvent(event: { t: string, d: any })` - method to push an event from the client to the Live server -- `handleEvent(event: string, cb: ((payload: any) => void))` - method to handle an event pushed from the server. +**v2 island:** -See the [chat example](https://github.com/jfyne/live/tree/master/examples/chat) for usage. +```go +// v2 +func NewMyIsland() (*live.Island, error) { + island, _ := live.NewIsland( + "my-island", + live.WithMount(func(ctx context.Context, props live.Props, children string) (any, error) { + return &IslandState{}, nil + }), + live.WithRender(func(ctx context.Context, rc *live.IslandRenderContext) (io.Reader, error) { + // Render only this island + return renderIsland(rc.State), nil + }), + ) + island.HandleEvent("click", clickHandler) + return island, nil +} -### Integrating with your app +live.RegisterIsland("my-island", NewMyIsland) +``` -There are two ways to integrate javascript into your applications. The first is the simplest, using the built -in javascript handler. This includes client side code to initialise the live handler and automatically looks for -hooks at `window.Hooks`. All of the examples use this method. +**v1 HTML:** -To add a custom hook register it before including the `live.js` file. -```javascript -window.Hooks = window.Hooks || {}; -window.Hooks['my-hook'] = { - mount: function() { - // ... - } -}; +```html + +
+ +
+ ``` -Use the `live-hook` attribute to wire the hook with live. +**v2 HTML:** + ```html -
+ +

My Page

+ + + + ``` -See the [chat example](https://github.com/jfyne/live/tree/master/examples/chat) for usage. +### Key Differences -The second method is suited for more complex apps, there is a companion package published on npm. The version -should be kept in sync with the current go version. +1. **No full-page LiveViews**: v2 only supports islands. If you need interactivity across the whole page, create a root island. -```bash -> npm i @jfyne/live -``` +2. **Multiple islands per page**: v2 allows multiple independent islands on one page, each with isolated state. -This can then be used to initialise the live handler on a page +3. **Props instead of assigns**: v1 used `Socket.Assigns()` for state. v2 uses `Props` (read-only from HTML) and island state (mutable). -```typescript -import { Live } from '@jfyne/live'; +4. **Session management**: You must manage session IDs and transport endpoints. v1 handled this automatically. -const hooks = {}; +5. **Client initialization**: v1 auto-initialized on page load. v2 uses custom elements that initialize when added to the DOM. -const live = new Live(hooks); -live.init(); -``` +6. **Event routing**: v2 requires explicit event routing via `engine.RouteEvent()`. v1 routed automatically. + +### Breaking Changes Summary + +- Removed `Handler`, `NewHandler()`, `NewHttpHandler()` +- Removed `Socket.Assigns()` - use island state instead +- Removed `page.Component` - use `Island` instead +- Removed `RenderSocket()` - use `Island.Render()` +- Removed `WithTemplateRenderer()` - implement render handler directly +- Removed automatic WebSocket endpoint - you must set up transports +- Client library rewritten - `` custom element replaces `Live.init()` -This allows more control over how hooks are passed to live, and when it should be initialised. It is expected -that you would then build your compiled javsacript and serve it. See the -[alpine example](https://github.com/jfyne/live/tree/master/examples/alpine). +## Advanced Topics -## Errors and exceptions +### Server-Pushed Updates -There are two types of errors in a live handler, and how these are handled are separate. +Islands can receive server-pushed updates via self-handlers: + +```go +island.HandleSelf("refresh", func(ctx context.Context, state any, data any) (any, error) { + // Update state based on server push + return updatedState, nil +}) -### Unexpected errors +// Trigger from server +engine.BroadcastToIsland(sessionID, islandID, "refresh", data) +``` -Errors that occur during the initial mount, initial render and web socket -upgrade process are handled by the handler `ErrorHandler` func. +### Broadcasting -Errors that occur while handling incoming web socket messages will trigger -a response back with the error. +Broadcast events to multiple islands: -### Expected errors +```go +// Broadcast to all instances of an island type +engine.BroadcastToIslandType(islandType, "update", data) -In general errors which you expect to happen such as form validations etc. -should be handled by just updating the data on the socket and -re-rendering. +// Broadcast to a specific island instance +engine.BroadcastToIsland(sessionID, islandID, "update", data) +``` -If you return an error in the event handler live will send an `"err"` event -to the socket. You can handle this with a hook. An example of this can be -seen in the [error example](https://github.com/jfyne/live/tree/master/examples/error). +### Custom State Stores -## Loading state and errors +Implement `IslandStateStore` interface for custom state persistence (Redis, database, etc.): -By default, the following classes are applied to the handlers body: +```go +type IslandStateStore interface { + Get(sessionID SessionID, islandID IslandID) (any, error) + Set(sessionID SessionID, islandID IslandID, state any, ttl time.Duration) error + Delete(sessionID SessionID, islandID IslandID) error +} +``` -- `live-connected` - applied when the view has connected to the server -- `live-disconnected` - applied when the view is not connected to the server -- `live-error` - applied when an error occurs on the server. Note, this class will be applied in conjunction with `live-disconnected` if connection to the server is lost. +### Nested Islands -All `live-` event bindings apply their own css classes when pushed. For example the following markup: +Islands can contain other islands. Each maintains its own state: ```html - + +

Parent Island

+ +

Child Island

+
+
``` -On click, would receive the `live-click-loading` class, and on keydown would -receive the `live-keydown-loading` class. The css loading classes are maintained -until an acknowledgement is received on the client for the pushed event. +Islands are treated as opaque boundaries - parent renders don't diff into child content. -The following events receive css loading classes: +## API Reference -- `live-click` - `live-click-loading` -- `live-change` - `live-change-loading` -- `live-submit` - `live-submit-loading` -- `live-focus` - `live-focus-loading` -- `live-blur` - `live-blur-loading` -- `live-window-keydown` - `live-keydown-loading` -- `live-window-keyup` - `live-keyup-loading` +### Core Types -## Broadcasting to different nodes +```go +type Island struct { + Name string + Mount IslandMountHandler + Render IslandRenderHandler + Unmount IslandUnmountHandler +} -In production it is often required to have multiple instances of the same application running, in order to handle this -live has a PubSub element. This allows nodes to publish onto topics and receive those messages as if they were all -running as the same instance. See the [cluster example](https://github.com/jfyne/live/tree/master/examples/cluster) for -usage. +type IslandInstance struct { + ID string + Type string +} -## Uploads +type Props map[string]any -Live supports interactive file uploads with progress indication. See the [uploads example](https://github.com/jfyne/live/tree/master/examples/uploads) -for usage. +type Session struct { + ID SessionID +} -### Features +type IslandEngine struct { + // Manages sessions and islands +} -Accept specification - Define accepted file types, max number of entries, max file size, etc. When the client -selects file(s), the file metadata can be validated with a helper function. +type Transport interface { + Send(Event) error + Events() <-chan Event + Close() error +} +``` -Reactive entries - Uploads are populated in the `.Uploads` template context. Entries automatically respond -to progress and errors. +### Functions -### Entry validation +```go +// Island creation +func NewIsland(name string, configs ...IslandConfig) (*Island, error) +func WithMount(handler IslandMountHandler) IslandConfig +func WithRender(handler IslandRenderHandler) IslandConfig +func WithUnmount(handler IslandUnmountHandler) IslandConfig + +// Registration +func RegisterIsland(name string, constructor IslandConstructor) error +func GetIsland(name string) (IslandConstructor, error) +func ListIslands() []string + +// Engine +func NewIslandEngine(ctx context.Context, registry *IslandRegistry, stateStore IslandStateStore) *IslandEngine +func (e *IslandEngine) AddSession(session *Session) +func (e *IslandEngine) DeleteSession(sessionID SessionID) +func (e *IslandEngine) MountIsland(sessionID SessionID, islandID IslandID, islandType string, props Props) (*IslandInstance, error) +func (e *IslandEngine) RouteEvent(sessionID SessionID, event Event) error + +// Transport +func UpgradeWebSocket(ctx context.Context, w http.ResponseWriter, r *http.Request, config *TransportConfig) (Transport, error) +func NewSSETransportFactory(config *TransportConfig) *SSETransportFactory +``` -File selection triggers the usual form change event and there is a helper function to validate the uploads. -Use `live.ValidateUploads` to validate the incoming files. Any validation errors will be available in the `.Uploads` -context in the template. +See [pkg.go.dev](https://pkg.go.dev/github.com/jfyne/live) for complete API documentation. -### Consume the uploads +--- -When a form is submitted files will first be uploaded to a staging area, then the submit event is triggered. Within the event -handler use the `live.ConsumeUploads` helper function to then move the uploaded files to where you need them. +Built with ⚡ by [@jfyne](https://github.com/jfyne) diff --git a/broadcast.go b/broadcast.go new file mode 100644 index 0000000..f720157 --- /dev/null +++ b/broadcast.go @@ -0,0 +1,115 @@ +package live + +import ( + "context" + "sync" +) + +// BroadcastTransport is the interface for broadcast message delivery. +// Implementations deliver messages from a publisher to all subscribers. +type BroadcastTransport interface { + Publish(ctx context.Context, topic string, msg Event) error + Listen(ctx context.Context, b *Broadcast) error +} + +// BroadcastMessage wraps a topic and event for transport delivery. +type BroadcastMessage struct { + Topic string + Msg Event +} + +// subscription links an island type to an engine for a topic. +type subscription struct { + islandType string + engine *IslandEngine +} + +// Broadcast orchestrates pub/sub message delivery to island engines. +// It manages topic subscriptions and routes incoming messages to the +// correct engines via BroadcastSelfToIslandType. +type Broadcast struct { + transport BroadcastTransport + mu sync.RWMutex + handlers map[string][]subscription +} + +// NewBroadcast creates a new Broadcast and starts the transport listener +// in a background goroutine. +func NewBroadcast(ctx context.Context, transport BroadcastTransport) *Broadcast { + b := &Broadcast{ + transport: transport, + handlers: make(map[string][]subscription), + } + go transport.Listen(ctx, b) + return b +} + +// Subscribe registers an engine to receive messages on a topic for a specific +// island type. When a message arrives on the topic, BroadcastSelfToIslandType +// is called on the engine for the given islandType. +func (b *Broadcast) Subscribe(topic string, islandType string, engine *IslandEngine) { + b.mu.Lock() + defer b.mu.Unlock() + b.handlers[topic] = append(b.handlers[topic], subscription{ + islandType: islandType, + engine: engine, + }) +} + +// Publish sends a message to the transport for delivery to all subscribers. +func (b *Broadcast) Publish(ctx context.Context, topic string, msg Event) error { + return b.transport.Publish(ctx, topic, msg) +} + +// Receive is called by the transport when a message arrives. +// It routes the event to all subscribed engines as a self-event via +// BroadcastSelfToIslandType. +func (b *Broadcast) Receive(topic string, msg Event) { + b.mu.RLock() + subs := b.handlers[topic] + b.mu.RUnlock() + + for _, sub := range subs { + sub.engine.BroadcastSelfToIslandType(sub.islandType, msg) + } +} + +// LocalTransport is an in-memory broadcast transport using an unbuffered channel. +// Publish blocks until Listen receives the message, ensuring delivery ordering. +type LocalTransport struct { + queue chan BroadcastMessage +} + +// NewLocalTransport creates a new LocalTransport with an unbuffered channel. +func NewLocalTransport() *LocalTransport { + return &LocalTransport{ + queue: make(chan BroadcastMessage), // unbuffered: Publish blocks until Listen receives + } +} + +// Publish sends a message to the local channel. +// It blocks until the Listen goroutine receives the message or the context is cancelled. +func (l *LocalTransport) Publish(ctx context.Context, topic string, msg Event) error { + select { + case l.queue <- BroadcastMessage{Topic: topic, Msg: msg}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// Listen reads messages from the channel and calls b.Receive for each one. +// It runs until the channel is closed or the context is cancelled. +func (l *LocalTransport) Listen(ctx context.Context, b *Broadcast) error { + for { + select { + case msg, ok := <-l.queue: + if !ok { + return nil + } + b.Receive(msg.Topic, msg.Msg) + case <-ctx.Done(): + return ctx.Err() + } + } +} diff --git a/broadcast_test.go b/broadcast_test.go new file mode 100644 index 0000000..2c08c12 --- /dev/null +++ b/broadcast_test.go @@ -0,0 +1,656 @@ +package live + +import ( + "context" + "io" + "strings" + "testing" + "time" +) + +// registerChatIsland registers a "chat" island type with a self-handler for +// "new-message" that appends the message string to the messages slice in state. +// This helper is shared by multiple broadcast tests. +func registerChatIsland(t *testing.T, registry *IslandRegistry) { + t.Helper() + err := registry.Register("chat", func() (*Island, error) { + island, err := NewIsland("chat", + WithMount(func(ctx context.Context, props Props, children string) (any, error) { + return map[string]any{"messages": []string{}}, nil + }), + WithRender(func(ctx context.Context, rc *IslandRenderContext) (io.Reader, error) { + return strings.NewReader("
chat
"), nil + }), + ) + if err != nil { + return nil, err + } + island.HandleSelf("new-message", func(ctx context.Context, state any, data any) (any, error) { + stateMap := state.(map[string]any) + messages := stateMap["messages"].([]string) + stateMap["messages"] = append(messages, data.(string)) + return stateMap, nil + }) + return island, nil + }) + if err != nil { + t.Fatalf("failed to register chat island: %v", err) + } +} + +// setupEngineWithChatIsland creates a new engine with a "chat" island mounted +// in a single session. Returns the engine, session, and transport for assertions. +func setupEngineWithChatIsland(t *testing.T, ctx context.Context, sessionID SessionID, islandID IslandID) (*IslandEngine, *Session, *engineMockTransport) { + t.Helper() + + registry := NewIslandRegistry() + registerChatIsland(t, registry) + + stateStore := NewMemoryIslandStateStore(ctx, 1*time.Minute) + engine := NewIslandEngine(ctx, registry, stateStore) + t.Cleanup(func() { engine.Close() }) + + transport := newEngineMockTransport() + session := NewSession(ctx, sessionID, transport) + engine.AddSession(session) + time.Sleep(10 * time.Millisecond) + + _, err := engine.MountIsland(sessionID, islandID, "chat", Props{}) + if err != nil { + t.Fatalf("failed to mount chat island: %v", err) + } + + // Clear mounting events before test assertions. + transport.mu.Lock() + transport.sent = []Event{} + transport.mu.Unlock() + + return engine, session, transport +} + +// --------------------------------------------------------------------------- +// Regression: existing BroadcastToIslandType continues to work after adding +// the new BroadcastSelfToIslandType. These tests verify the old behaviour is +// preserved and should PASS. +// --------------------------------------------------------------------------- + +// TestBroadcastRegression_BroadcastToIslandType verifies that the pre-existing +// BroadcastToIslandType method still sends raw events (not self-events) to all +// islands of the given type. This is a regression guard — it must PASS. +func TestBroadcastRegression_BroadcastToIslandType(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + registry := NewIslandRegistry() + registerChatIsland(t, registry) + + stateStore := NewMemoryIslandStateStore(ctx, 1*time.Minute) + engine := NewIslandEngine(ctx, registry, stateStore) + defer engine.Close() + + transport := newEngineMockTransport() + session := NewSession(ctx, "session-1", transport) + engine.AddSession(session) + time.Sleep(10 * time.Millisecond) + + _, err := engine.MountIsland("session-1", "chat-1", "chat", Props{}) + if err != nil { + t.Fatalf("failed to mount island: %v", err) + } + + // Clear mount events. + transport.mu.Lock() + transport.sent = []Event{} + transport.mu.Unlock() + + // Broadcast a raw event to all "chat" islands. + broadcastEvent := Event{ + T: "update", + Data: []byte(`{"text":"hello"}`), + } + engine.BroadcastToIslandType("chat", broadcastEvent) + time.Sleep(10 * time.Millisecond) + + sent := transport.GetSent() + if len(sent) == 0 { + t.Fatal("expected at least one event to be sent via BroadcastToIslandType") + } + if sent[0].T != "update" { + t.Errorf("expected event type 'update', got %q", sent[0].T) + } + if sent[0].Island != "chat-1" { + t.Errorf("expected island 'chat-1', got %q", sent[0].Island) + } +} + +// --------------------------------------------------------------------------- +// New feature tests (RED state — will fail until broadcast.go is implemented) +// --------------------------------------------------------------------------- + +// TestBroadcastSelfToIslandType verifies that calling BroadcastSelfToIslandType +// on the engine iterates all sessions, finds islands of the given type, and +// routes a self-event to each one via RouteEvent with SelfData set. This causes +// the island's self-handler to fire and its state to be updated. +// +// Scenario (from plan): +// +// Given an engine with two sessions, each containing a "chat" island +// When BroadcastSelfToIslandType("chat", event) is called +// Then the self-handler on both island instances is invoked +// And both islands are re-rendered with updated state +func TestBroadcastSelfToIslandType(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + registry := NewIslandRegistry() + registerChatIsland(t, registry) + + stateStore := NewMemoryIslandStateStore(ctx, 1*time.Minute) + engine := NewIslandEngine(ctx, registry, stateStore) + defer engine.Close() + + // Set up two sessions, each with a "chat" island. + transport1 := newEngineMockTransport() + session1 := NewSession(ctx, "session-1", transport1) + engine.AddSession(session1) + + transport2 := newEngineMockTransport() + session2 := NewSession(ctx, "session-2", transport2) + engine.AddSession(session2) + + time.Sleep(10 * time.Millisecond) + + instance1, err := engine.MountIsland("session-1", "chat-1", "chat", Props{}) + if err != nil { + t.Fatalf("failed to mount island in session 1: %v", err) + } + + instance2, err := engine.MountIsland("session-2", "chat-2", "chat", Props{}) + if err != nil { + t.Fatalf("failed to mount island in session 2: %v", err) + } + + // Clear mount events. + transport1.mu.Lock() + transport1.sent = []Event{} + transport1.mu.Unlock() + transport2.mu.Lock() + transport2.sent = []Event{} + transport2.mu.Unlock() + + // Call the new method: BroadcastSelfToIslandType routes a self-event to + // all matching island instances across all sessions. + selfEvent := Event{ + T: "new-message", + SelfData: "hello world", + } + engine.BroadcastSelfToIslandType("chat", selfEvent) + + // Give any async work time to complete. + time.Sleep(20 * time.Millisecond) + + // Verify instance1's self-handler fired and state was updated. + state1 := instance1.State().(map[string]any) + messages1 := state1["messages"].([]string) + if len(messages1) != 1 { + t.Errorf("session-1 chat island: expected 1 message, got %d", len(messages1)) + } else if messages1[0] != "hello world" { + t.Errorf("session-1 chat island: expected message 'hello world', got %q", messages1[0]) + } + + // Verify instance2's self-handler fired and state was updated. + state2 := instance2.State().(map[string]any) + messages2 := state2["messages"].([]string) + if len(messages2) != 1 { + t.Errorf("session-2 chat island: expected 1 message, got %d", len(messages2)) + } else if messages2[0] != "hello world" { + t.Errorf("session-2 chat island: expected message 'hello world', got %q", messages2[0]) + } + + // Verify patch events were sent to both transports (re-render after self-event). + sent1 := transport1.GetSent() + hasPatch1 := false + for _, e := range sent1 { + if e.T == EventPatch { + hasPatch1 = true + break + } + } + if !hasPatch1 { + t.Error("expected a patch event to be sent to session-1 transport after BroadcastSelfToIslandType") + } + + sent2 := transport2.GetSent() + hasPatch2 := false + for _, e := range sent2 { + if e.T == EventPatch { + hasPatch2 = true + break + } + } + if !hasPatch2 { + t.Error("expected a patch event to be sent to session-2 transport after BroadcastSelfToIslandType") + } +} + +// TestBroadcast_SubscribeAndReceive verifies that subscribing an engine to a +// topic and then calling Receive directly routes a self-event to the matching +// islands in that engine. +// +// This tests the Broadcast struct's Receive method in isolation without going +// through the transport layer. +func TestBroadcast_SubscribeAndReceive(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Create a LocalTransport (even though we call Receive directly, the Broadcast + // still needs a transport to be constructed). + lt := NewLocalTransport() + b := NewBroadcast(ctx, lt) + + // Set up an engine with a "chat" island. + registry := NewIslandRegistry() + registerChatIsland(t, registry) + + stateStore := NewMemoryIslandStateStore(ctx, 1*time.Minute) + engine := NewIslandEngine(ctx, registry, stateStore) + defer engine.Close() + + transport := newEngineMockTransport() + session := NewSession(ctx, "session-1", transport) + engine.AddSession(session) + time.Sleep(10 * time.Millisecond) + + instance, err := engine.MountIsland("session-1", "chat-1", "chat", Props{}) + if err != nil { + t.Fatalf("failed to mount island: %v", err) + } + + // Subscribe the engine to the "chat" topic for island type "chat". + b.Subscribe("chat", "chat", engine) + + // Call Receive directly to simulate a message arriving from the transport. + msg := Event{ + T: "new-message", + SelfData: "subscribe-test message", + } + b.Receive("chat", msg) + + // Give async work time to complete. + time.Sleep(20 * time.Millisecond) + + // Verify the self-handler on the island instance was invoked. + state := instance.State().(map[string]any) + messages := state["messages"].([]string) + if len(messages) != 1 { + t.Errorf("expected 1 message after Receive, got %d", len(messages)) + } else if messages[0] != "subscribe-test message" { + t.Errorf("expected message 'subscribe-test message', got %q", messages[0]) + } +} + +// TestBroadcast_PublishThroughLocalTransport verifies the end-to-end flow: +// a message published via Broadcast.Publish is delivered through LocalTransport +// to the subscribed engine, and the island's self-handler fires. +// +// Scenario (from plan): +// +// Given a Broadcast with LocalTransport +// And engine A subscribed to topic "chat" for island type "chat" +// And engine A has a session with a "chat" island mounted +// When a message is published to topic "chat" +// Then the "chat" island's self-handler receives the message +// And the island state is updated and re-rendered +func TestBroadcast_PublishThroughLocalTransport(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + lt := NewLocalTransport() + b := NewBroadcast(ctx, lt) + + // Set up engine with a mounted "chat" island. + registry := NewIslandRegistry() + registerChatIsland(t, registry) + + stateStore := NewMemoryIslandStateStore(ctx, 1*time.Minute) + engine := NewIslandEngine(ctx, registry, stateStore) + defer engine.Close() + + transport := newEngineMockTransport() + session := NewSession(ctx, "session-1", transport) + engine.AddSession(session) + time.Sleep(10 * time.Millisecond) + + instance, err := engine.MountIsland("session-1", "chat-1", "chat", Props{}) + if err != nil { + t.Fatalf("failed to mount island: %v", err) + } + + // Clear mount events. + transport.mu.Lock() + transport.sent = []Event{} + transport.mu.Unlock() + + // Subscribe engine to the topic. + b.Subscribe("chat", "chat", engine) + + // Publish a message via Broadcast. + publishedMsg := Event{ + T: "new-message", + SelfData: "broadcasted message", + } + if err := b.Publish(ctx, "chat", publishedMsg); err != nil { + t.Fatalf("Publish returned an error: %v", err) + } + + // The LocalTransport delivers asynchronously; give it time. + time.Sleep(50 * time.Millisecond) + + // Verify the island's self-handler was invoked. + state := instance.State().(map[string]any) + messages := state["messages"].([]string) + if len(messages) != 1 { + t.Errorf("expected 1 message after Publish, got %d", len(messages)) + } else if messages[0] != "broadcasted message" { + t.Errorf("expected message 'broadcasted message', got %q", messages[0]) + } + + // Verify a patch event was sent (island re-rendered). + sent := transport.GetSent() + hasPatch := false + for _, e := range sent { + if e.T == EventPatch { + hasPatch = true + break + } + } + if !hasPatch { + t.Error("expected a patch event after Publish — island should be re-rendered") + } +} + +// TestBroadcast_MultipleEngines verifies that two different engines both +// receive a broadcast when both are subscribed to the same topic. +// +// Scenario (from plan): +// +// Given a Broadcast with LocalTransport +// And engine A and engine B are both subscribed to topic "chat" for island type "chat" +// When a message is published to topic "chat" +// Then both engines receive the message +// And all matching islands in both engines have their self-handlers invoked +func TestBroadcast_MultipleEngines(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + lt := NewLocalTransport() + b := NewBroadcast(ctx, lt) + + // Engine A + registryA := NewIslandRegistry() + registerChatIsland(t, registryA) + storeA := NewMemoryIslandStateStore(ctx, 1*time.Minute) + engineA := NewIslandEngine(ctx, registryA, storeA) + defer engineA.Close() + + transportA := newEngineMockTransport() + sessionA := NewSession(ctx, "session-a", transportA) + engineA.AddSession(sessionA) + time.Sleep(10 * time.Millisecond) + + instanceA, err := engineA.MountIsland("session-a", "chat-a", "chat", Props{}) + if err != nil { + t.Fatalf("failed to mount island in engine A: %v", err) + } + + // Engine B + registryB := NewIslandRegistry() + registerChatIsland(t, registryB) + storeB := NewMemoryIslandStateStore(ctx, 1*time.Minute) + engineB := NewIslandEngine(ctx, registryB, storeB) + defer engineB.Close() + + transportB := newEngineMockTransport() + sessionB := NewSession(ctx, "session-b", transportB) + engineB.AddSession(sessionB) + time.Sleep(10 * time.Millisecond) + + instanceB, err := engineB.MountIsland("session-b", "chat-b", "chat", Props{}) + if err != nil { + t.Fatalf("failed to mount island in engine B: %v", err) + } + + // Subscribe both engines to the same topic. + b.Subscribe("chat", "chat", engineA) + b.Subscribe("chat", "chat", engineB) + + // Publish a message. + publishedMsg := Event{ + T: "new-message", + SelfData: "multi-engine message", + } + if err := b.Publish(ctx, "chat", publishedMsg); err != nil { + t.Fatalf("Publish returned an error: %v", err) + } + + // Allow delivery time. + time.Sleep(50 * time.Millisecond) + + // Verify engine A's island received the message. + stateA := instanceA.State().(map[string]any) + messagesA := stateA["messages"].([]string) + if len(messagesA) != 1 { + t.Errorf("engine A chat island: expected 1 message, got %d", len(messagesA)) + } else if messagesA[0] != "multi-engine message" { + t.Errorf("engine A chat island: expected 'multi-engine message', got %q", messagesA[0]) + } + + // Verify engine B's island received the message. + stateB := instanceB.State().(map[string]any) + messagesB := stateB["messages"].([]string) + if len(messagesB) != 1 { + t.Errorf("engine B chat island: expected 1 message, got %d", len(messagesB)) + } else if messagesB[0] != "multi-engine message" { + t.Errorf("engine B chat island: expected 'multi-engine message', got %q", messagesB[0]) + } +} + +// TestLocalTransport_RoundTrip verifies that a message published via +// LocalTransport.Publish is received by the LocalTransport's Listen goroutine +// and ultimately delivered to a subscribed engine's island self-handler. +// +// This is the core transport-layer round-trip test: Publish → channel → Listen +// → Broadcast.Receive → BroadcastSelfToIslandType → island self-handler. +func TestLocalTransport_RoundTrip(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + lt := NewLocalTransport() + + // Set up a real engine with a mounted "chat" island. + registry := NewIslandRegistry() + registerChatIsland(t, registry) + + stateStore := NewMemoryIslandStateStore(ctx, 1*time.Minute) + engine := NewIslandEngine(ctx, registry, stateStore) + defer engine.Close() + + transport := newEngineMockTransport() + session := NewSession(ctx, "session-1", transport) + engine.AddSession(session) + time.Sleep(10 * time.Millisecond) + + instance, err := engine.MountIsland("session-1", "chat-1", "chat", Props{}) + if err != nil { + t.Fatalf("failed to mount island: %v", err) + } + + // NewBroadcast starts lt.Listen in a background goroutine. + b := NewBroadcast(ctx, lt) + b.Subscribe("chat", "chat", engine) + + // Publish a message directly on the LocalTransport. + msg := Event{ + T: "new-message", + SelfData: "round-trip message", + } + if err := lt.Publish(ctx, "chat", msg); err != nil { + t.Fatalf("LocalTransport.Publish failed: %v", err) + } + + // Allow the Listen goroutine to process the message and route it. + time.Sleep(50 * time.Millisecond) + + // Verify the self-handler on the island was invoked. + state := instance.State().(map[string]any) + messages := state["messages"].([]string) + if len(messages) != 1 { + t.Errorf("expected 1 message after LocalTransport round-trip, got %d", len(messages)) + } else if messages[0] != "round-trip message" { + t.Errorf("expected message 'round-trip message', got %q", messages[0]) + } +} + +// TestBroadcastSelfToIslandType_NonMatchingType verifies that +// BroadcastSelfToIslandType does NOT invoke self-handlers on islands of a +// different type. This is a correctness guard for the new engine method. +func TestBroadcastSelfToIslandType_NonMatchingType(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + registry := NewIslandRegistry() + registerChatIsland(t, registry) + + // Also register a "counter" island with a self-handler. + counterSelfCalled := false + err := registry.Register("counter", func() (*Island, error) { + island, err := NewIsland("counter", + WithMount(func(ctx context.Context, props Props, children string) (any, error) { + return map[string]any{"count": 0}, nil + }), + WithRender(func(ctx context.Context, rc *IslandRenderContext) (io.Reader, error) { + return strings.NewReader("
counter
"), nil + }), + ) + if err != nil { + return nil, err + } + island.HandleSelf("increment", func(ctx context.Context, state any, data any) (any, error) { + counterSelfCalled = true + stateMap := state.(map[string]any) + stateMap["count"] = stateMap["count"].(int) + 1 + return stateMap, nil + }) + return island, nil + }) + if err != nil { + t.Fatalf("failed to register counter island: %v", err) + } + + stateStore := NewMemoryIslandStateStore(ctx, 1*time.Minute) + engine := NewIslandEngine(ctx, registry, stateStore) + defer engine.Close() + + transport := newEngineMockTransport() + session := NewSession(ctx, "session-1", transport) + engine.AddSession(session) + time.Sleep(10 * time.Millisecond) + + // Mount a counter island AND a chat island. + _, err = engine.MountIsland("session-1", "counter-1", "counter", Props{}) + if err != nil { + t.Fatalf("failed to mount counter island: %v", err) + } + + chatInstance, err := engine.MountIsland("session-1", "chat-1", "chat", Props{}) + if err != nil { + t.Fatalf("failed to mount chat island: %v", err) + } + + // Broadcast only to "chat" islands. + selfEvent := Event{ + T: "new-message", + SelfData: "only for chat", + } + engine.BroadcastSelfToIslandType("chat", selfEvent) + + time.Sleep(20 * time.Millisecond) + + // Chat island should have received the message. + chatState := chatInstance.State().(map[string]any) + chatMessages := chatState["messages"].([]string) + if len(chatMessages) != 1 { + t.Errorf("chat island: expected 1 message, got %d", len(chatMessages)) + } + + // Counter island should NOT have been called. + if counterSelfCalled { + t.Error("counter island self-handler should NOT have been called when broadcasting to 'chat' type") + } +} + +// TestBroadcast_ReceiveCallsBroadcastSelfToIslandType verifies that calling +// Broadcast.Receive directly triggers BroadcastSelfToIslandType on all +// subscribed engines for the given topic. +func TestBroadcast_ReceiveCallsBroadcastSelfToIslandType(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + lt := NewLocalTransport() + b := NewBroadcast(ctx, lt) + + // Engine with two sessions, each having a "chat" island. + registry := NewIslandRegistry() + registerChatIsland(t, registry) + + stateStore := NewMemoryIslandStateStore(ctx, 1*time.Minute) + engine := NewIslandEngine(ctx, registry, stateStore) + defer engine.Close() + + transport1 := newEngineMockTransport() + session1 := NewSession(ctx, "session-1", transport1) + engine.AddSession(session1) + + transport2 := newEngineMockTransport() + session2 := NewSession(ctx, "session-2", transport2) + engine.AddSession(session2) + + time.Sleep(10 * time.Millisecond) + + instance1, err := engine.MountIsland("session-1", "chat-1", "chat", Props{}) + if err != nil { + t.Fatalf("failed to mount island in session-1: %v", err) + } + + instance2, err := engine.MountIsland("session-2", "chat-2", "chat", Props{}) + if err != nil { + t.Fatalf("failed to mount island in session-2: %v", err) + } + + b.Subscribe("chat", "chat", engine) + + msg := Event{ + T: "new-message", + SelfData: "receive test", + } + b.Receive("chat", msg) + + time.Sleep(20 * time.Millisecond) + + // Both island instances should have received the message. + state1 := instance1.State().(map[string]any) + messages1 := state1["messages"].([]string) + if len(messages1) != 1 { + t.Errorf("session-1 island: expected 1 message, got %d", len(messages1)) + } else if messages1[0] != "receive test" { + t.Errorf("session-1 island: expected 'receive test', got %q", messages1[0]) + } + + state2 := instance2.State().(map[string]any) + messages2 := state2["messages"].([]string) + if len(messages2) != 1 { + t.Errorf("session-2 island: expected 1 message, got %d", len(messages2)) + } else if messages2[0] != "receive test" { + t.Errorf("session-2 island: expected 'receive test', got %q", messages2[0]) + } +} diff --git a/context.go b/context.go index adf9887..ff51ce8 100644 --- a/context.go +++ b/context.go @@ -2,14 +2,19 @@ package live import ( "context" + "fmt" "net/http" ) type contextKey string const ( - requestKey contextKey = "context_request" - writerKey contextKey = "context_writer" + requestKey contextKey = "context_request" + writerKey contextKey = "context_writer" + sessionIDCtxKey contextKey = "context_session_id" + islandIDCtxKey contextKey = "context_island_id" + engineCtxKey contextKey = "context_engine" + selfEventQueueCtxKey contextKey = "context_self_event_queue" ) // contextWithRequest embed the initiating request within the context. @@ -17,7 +22,11 @@ func contextWithRequest(ctx context.Context, r *http.Request) context.Context { return context.WithValue(ctx, requestKey, r) } -// Request pulls out an initiating request from a context. +// Request extracts the original HTTP request from a context. +// This is useful in island handlers to access request headers, cookies, or other +// HTTP metadata. +// +// Returns nil if no request is stored in the context. func Request(ctx context.Context) *http.Request { data := ctx.Value(requestKey) r, ok := data.(*http.Request) @@ -32,7 +41,10 @@ func contextWithWriter(ctx context.Context, w http.ResponseWriter) context.Conte return context.WithValue(ctx, writerKey, w) } -// Writer pulls out a response writer from a context. +// Writer extracts the HTTP response writer from a context. +// This is useful in island handlers to write headers or perform HTTP-specific operations. +// +// Returns nil if no writer is stored in the context. func Writer(ctx context.Context) http.ResponseWriter { data := ctx.Value(writerKey) w, ok := data.(http.ResponseWriter) @@ -41,3 +53,89 @@ func Writer(ctx context.Context) http.ResponseWriter { } return w } + +// contextWithSessionID embeds the session ID within the context. +func contextWithSessionID(ctx context.Context, id SessionID) context.Context { + return context.WithValue(ctx, sessionIDCtxKey, id) +} + +// sessionIDFromContext extracts the session ID from a context. +// Returns an empty SessionID if no session ID is stored in the context. +func sessionIDFromContext(ctx context.Context) SessionID { + data := ctx.Value(sessionIDCtxKey) + id, ok := data.(SessionID) + if !ok { + return "" + } + return id +} + +// contextWithIslandID embeds the island ID within the context. +func contextWithIslandID(ctx context.Context, id IslandID) context.Context { + return context.WithValue(ctx, islandIDCtxKey, id) +} + +// islandIDFromContext extracts the island ID from a context. +// Returns an empty IslandID if no island ID is stored in the context. +func islandIDFromContext(ctx context.Context) IslandID { + data := ctx.Value(islandIDCtxKey) + id, ok := data.(IslandID) + if !ok { + return "" + } + return id +} + +// contextWithEngine embeds the IslandEngine within the context. +func contextWithEngine(ctx context.Context, engine *IslandEngine) context.Context { + return context.WithValue(ctx, engineCtxKey, engine) +} + +// engineFromContext extracts the IslandEngine from a context. +// Returns nil if no engine is stored in the context. +func engineFromContext(ctx context.Context) *IslandEngine { + data := ctx.Value(engineCtxKey) + engine, ok := data.(*IslandEngine) + if !ok { + return nil + } + return engine +} + +// contextWithSelfEventQueue embeds the self-event queue within the context. +func contextWithSelfEventQueue(ctx context.Context, queue *[]Event) context.Context { + return context.WithValue(ctx, selfEventQueueCtxKey, queue) +} + +// selfEventQueueFromContext extracts the self-event queue from a context. +// Returns nil if no queue is stored in the context. +func selfEventQueueFromContext(ctx context.Context) *[]Event { + data := ctx.Value(selfEventQueueCtxKey) + queue, ok := data.(*[]Event) + if !ok { + return nil + } + return queue +} + +// sessionFromContext extracts the Session from a context by looking up the +// engine and session ID, then calling engine.GetSession. +// Returns an error if the engine, session ID, or session is not found. +func sessionFromContext(ctx context.Context) (*Session, error) { + engine := engineFromContext(ctx) + if engine == nil { + return nil, fmt.Errorf("sessionFromContext: no engine in context") + } + + sessionID := sessionIDFromContext(ctx) + if sessionID == "" { + return nil, fmt.Errorf("sessionFromContext: no session ID in context") + } + + session, ok := engine.GetSession(sessionID) + if !ok { + return nil, fmt.Errorf("sessionFromContext: session %q not found", sessionID) + } + + return session, nil +} diff --git a/context_test.go b/context_test.go new file mode 100644 index 0000000..544a97a --- /dev/null +++ b/context_test.go @@ -0,0 +1,269 @@ +package live + +import ( + "context" + "fmt" + "net/http" + "sync" + "testing" +) + +func TestContextWithRequest(t *testing.T) { + t.Run("stores and retrieves request correctly", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://example.com/test", nil) + ctx := context.Background() + + ctx = contextWithRequest(ctx, req) + retrievedReq := Request(ctx) + if retrievedReq != req { + t.Errorf("Expected request %v, got %v", req, retrievedReq) + } + }) + + t.Run("returns nil for missing request", func(t *testing.T) { + req := Request(context.Background()) + if req != nil { + t.Errorf("Expected nil request, got %v", req) + } + }) +} + +func TestContextWithWriter(t *testing.T) { + t.Run("stores and retrieves writer correctly", func(t *testing.T) { + w := &testResponseWriter{} + ctx := context.Background() + + ctx = contextWithWriter(ctx, w) + retrievedWriter := Writer(ctx) + if retrievedWriter != w { + t.Errorf("Expected writer %v, got %v", w, retrievedWriter) + } + }) + + t.Run("returns nil for missing writer", func(t *testing.T) { + w := Writer(context.Background()) + if w != nil { + t.Errorf("Expected nil writer, got %v", w) + } + }) +} + +func TestConcurrentAccess(t *testing.T) { + t.Run("context utilities are safe with concurrent access", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://example.com/test", nil) + w := &testResponseWriter{} + + ctx := context.Background() + ctx = contextWithRequest(ctx, req) + ctx = contextWithWriter(ctx, w) + + // Concurrent reads should be safe + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + retrievedReq := Request(ctx) + retrievedWriter := Writer(ctx) + if retrievedReq != req { + t.Errorf("Expected request %v, got %v", req, retrievedReq) + } + if retrievedWriter != w { + t.Errorf("Expected writer %v, got %v", w, retrievedWriter) + } + }() + } + wg.Wait() + }) +} + +func TestTypeMismatch(t *testing.T) { + t.Run("Request returns nil for type mismatch", func(t *testing.T) { + ctx := context.WithValue(context.Background(), requestKey, "not a request") + req := Request(ctx) + if req != nil { + t.Errorf("Expected nil for type mismatch, got %v", req) + } + }) + + t.Run("Writer returns nil for type mismatch", func(t *testing.T) { + ctx := context.WithValue(context.Background(), writerKey, "not a writer") + w := Writer(ctx) + if w != nil { + t.Errorf("Expected nil for type mismatch, got %v", w) + } + }) +} + +func TestNilContextValues(t *testing.T) { + t.Run("Request handles nil context value", func(t *testing.T) { + ctx := context.WithValue(context.Background(), requestKey, nil) + req := Request(ctx) + if req != nil { + t.Errorf("Expected nil for nil context value, got %v", req) + } + }) + + t.Run("Writer handles nil context value", func(t *testing.T) { + ctx := context.WithValue(context.Background(), writerKey, nil) + w := Writer(ctx) + if w != nil { + t.Errorf("Expected nil for nil context value, got %v", w) + } + }) +} + +func TestMultipleGoroutines(t *testing.T) { + t.Run("multiple goroutines can safely access same context", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://example.com/test", nil) + w := &testResponseWriter{} + + ctx := context.Background() + ctx = contextWithRequest(ctx, req) + ctx = contextWithWriter(ctx, w) + + const goroutines = 1000 + var wg sync.WaitGroup + errors := make(chan error, goroutines) + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + + // Read both values + retrievedReq := Request(ctx) + retrievedWriter := Writer(ctx) + + if retrievedReq != req { + errors <- fmt.Errorf("goroutine %d: expected request %v, got %v", id, req, retrievedReq) + return + } + if retrievedWriter != w { + errors <- fmt.Errorf("goroutine %d: expected writer %v, got %v", id, w, retrievedWriter) + return + } + }(i) + } + + wg.Wait() + close(errors) + + // Check for any errors + for err := range errors { + t.Error(err) + } + }) +} + +func TestContextWithSessionID(t *testing.T) { + t.Run("stores and retrieves session ID correctly", func(t *testing.T) { + ctx := context.Background() + id := SessionID("test-session-123") + + ctx = contextWithSessionID(ctx, id) + retrieved := sessionIDFromContext(ctx) + if retrieved != id { + t.Errorf("Expected session ID %q, got %q", id, retrieved) + } + }) + + t.Run("returns empty session ID for missing key", func(t *testing.T) { + retrieved := sessionIDFromContext(context.Background()) + if retrieved != SessionID("") { + t.Errorf("Expected empty session ID, got %q", retrieved) + } + }) +} + +func TestContextWithIslandID(t *testing.T) { + t.Run("stores and retrieves island ID correctly", func(t *testing.T) { + ctx := context.Background() + id := IslandID("counter-1") + + ctx = contextWithIslandID(ctx, id) + retrieved := islandIDFromContext(ctx) + if retrieved != id { + t.Errorf("Expected island ID %q, got %q", id, retrieved) + } + }) + + t.Run("returns empty island ID for missing key", func(t *testing.T) { + retrieved := islandIDFromContext(context.Background()) + if retrieved != IslandID("") { + t.Errorf("Expected empty island ID, got %q", retrieved) + } + }) +} + +func TestContextWithEngine(t *testing.T) { + t.Run("stores and retrieves engine correctly", func(t *testing.T) { + ctx := context.Background() + engine := &IslandEngine{} + + ctx = contextWithEngine(ctx, engine) + retrieved := engineFromContext(ctx) + if retrieved != engine { + t.Errorf("Expected engine %p, got %p", engine, retrieved) + } + }) + + t.Run("returns nil for missing engine", func(t *testing.T) { + retrieved := engineFromContext(context.Background()) + if retrieved != nil { + t.Errorf("Expected nil engine, got %v", retrieved) + } + }) +} + +func TestContextWithSelfEventQueue(t *testing.T) { + t.Run("stores and retrieves self event queue correctly", func(t *testing.T) { + ctx := context.Background() + queue := &[]Event{} + + ctx = contextWithSelfEventQueue(ctx, queue) + retrieved := selfEventQueueFromContext(ctx) + if retrieved != queue { + t.Errorf("Expected queue %p, got %p", queue, retrieved) + } + }) + + t.Run("returns nil for missing queue", func(t *testing.T) { + retrieved := selfEventQueueFromContext(context.Background()) + if retrieved != nil { + t.Errorf("Expected nil queue, got %v", retrieved) + } + }) + + t.Run("appending to queue modifies original slice", func(t *testing.T) { + ctx := context.Background() + queue := &[]Event{} + + ctx = contextWithSelfEventQueue(ctx, queue) + retrieved := selfEventQueueFromContext(ctx) + + // Append via the retrieved pointer + *retrieved = append(*retrieved, Event{T: "test-event"}) + + // The original queue pointer should see the change + if len(*queue) != 1 { + t.Errorf("Expected original queue to have 1 event, got %d", len(*queue)) + } + if (*queue)[0].T != "test-event" { + t.Errorf("Expected event type %q, got %q", "test-event", (*queue)[0].T) + } + }) +} + +// testResponseWriter is a minimal http.ResponseWriter implementation for testing +type testResponseWriter struct{} + +func (w *testResponseWriter) Header() http.Header { + return http.Header{} +} + +func (w *testResponseWriter) Write([]byte) (int, error) { + return 0, nil +} + +func (w *testResponseWriter) WriteHeader(statusCode int) {} \ No newline at end of file diff --git a/coverage.html b/coverage.html new file mode 100644 index 0000000..b71cd3b --- /dev/null +++ b/coverage.html @@ -0,0 +1,3762 @@ + + + + + + live: Go Coverage Report + + + +
+ +
+ not tracked + + no coverage + low coverage + * + * + * + * + * + * + * + * + high coverage + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/diff.go b/diff.go index c563118..9842403 100644 --- a/diff.go +++ b/diff.go @@ -12,21 +12,28 @@ import ( const _debug = false -// LiveRendered an attribute key to show that a DOM has been rendered by live. +// LiveRendered is an attribute key that indicates a DOM has been rendered by live. +// The live client JavaScript checks for this attribute to determine if it should +// attempt to connect to the server. const LiveRendered = "live-rendered" // liveAnchorPrefix prefixes injected anchors. const liveAnchorPrefix = "_l" +const islandAnchorPrefix = "_i" const liveAnchorSep = -1 -// PatchAction available actions to take by a patch. +// PatchAction defines the type of modification a patch will perform on the DOM. type PatchAction uint32 -// Actions available. +// Patch actions define how the client should apply DOM updates. const ( + // Noop indicates no action should be taken for this patch. Noop PatchAction = iota + // Replace indicates the target node should be replaced with new content. Replace + // Append indicates new content should be appended to the target node's children. Append + // Prepend indicates new content should be prepended to the target node's children. Prepend ) @@ -39,10 +46,23 @@ func newAnchorGenerator() anchorGenerator { return anchorGenerator{idx: []int{}} } +// islandAnchorGenerator generates island-scoped IDs for nodes in the tree. +type islandAnchorGenerator struct { + islandID string + idx []int +} + +func newIslandAnchorGenerator(islandID string) islandAnchorGenerator { + return islandAnchorGenerator{islandID: islandID, idx: []int{}} +} + // inc increment the current index. func (n anchorGenerator) inc() anchorGenerator { o := make([]int, len(n.idx)) copy(o, n.idx) + if len(o) == 0 { + o = []int{0} + } o[len(o)-1]++ return anchorGenerator{idx: o} } @@ -67,11 +87,55 @@ func (n anchorGenerator) String() string { return out } -// Patch a location in the frontend dom. +// inc increment the current index. +func (n islandAnchorGenerator) inc() islandAnchorGenerator { + o := make([]int, len(n.idx)) + copy(o, n.idx) + if len(o) == 0 { + o = []int{0} + } + o[len(o)-1]++ + return islandAnchorGenerator{islandID: n.islandID, idx: o} +} + +// level increase the depth. +func (n islandAnchorGenerator) level() islandAnchorGenerator { + o := make([]int, len(n.idx)) + copy(o, n.idx) + o = append(o, liveAnchorSep, 0) + return islandAnchorGenerator{islandID: n.islandID, idx: o} +} + +func (n islandAnchorGenerator) String() string { + out := islandAnchorPrefix + "_" + n.islandID + for _, i := range n.idx { + if i == liveAnchorSep { + out += "_" + } else { + out += fmt.Sprintf("%d", i) + } + } + return out +} + +// Patch represents a DOM modification to be applied on the client side. +// Each patch targets a specific anchor point in the DOM and contains +// the HTML content and action to perform. type Patch struct { - Anchor string - Action PatchAction - HTML string + // Anchor is the DOM element identifier where this patch should be applied. + // Anchors are generated automatically during rendering and use the format + // "_l" for page-level patches or "_i_" for island-scoped patches. + Anchor string `json:"Anchor"` + + // Action specifies how to apply the HTML content (Replace, Append, Prepend, or Noop). + Action PatchAction `json:"Action"` + + // HTML is the HTML content to apply at the anchor point. + HTML string `json:"HTML"` + + // IslandID optionally identifies which island this patch belongs to. + // This is used for routing patches in multi-island scenarios. + IslandID string `json:"island_id,omitempty"` } func (p Patch) String() string { @@ -90,7 +154,34 @@ func (p Patch) String() string { return fmt.Sprintf("%s %s %s", p.Anchor, action, p.HTML) } -// Diff compare two node states and return patches. +// IslandPatch associates a set of patches with a specific island ID. +// This wrapper enables routing patches to the correct island instance +// when multiple islands exist on the same page. +type IslandPatch struct { + IslandID IslandID `json:"island_id"` + Patches []Patch `json:"patches"` +} + +// NewIslandPatch creates an IslandPatch wrapper with the given island ID. +// It automatically sets the IslandID field on each patch for consistency. +func NewIslandPatch(islandID IslandID, patches []Patch) IslandPatch { + // Set the IslandID on each patch + for i := range patches { + patches[i].IslandID = string(islandID) + } + return IslandPatch{ + IslandID: islandID, + Patches: patches, + } +} + +// Diff compares two HTML node trees and generates a minimal set of patches +// to transform the current tree into the proposed tree. +// +// The function automatically anchors both trees before comparing them, +// ensuring each significant node has a unique identifier for precise targeting. +// +// This is the page-level diff function. For island-scoped diffs, use DiffIsland. func Diff(current, proposed *html.Node) ([]Patch, error) { patches := diffTrees(current, proposed) output := make([]Patch, len(patches)) @@ -118,6 +209,87 @@ func Diff(current, proposed *html.Node) ([]Patch, error) { return output, nil } +// DiffIsland compares two HTML strings within an island scope and returns patches +// with the island ID set. The proposed HTML is parsed and anchored with island-scoped +// anchors before diffing. +func DiffIsland(islandID IslandID, current, proposed string) ([]Patch, error) { + // Parse current HTML + currentNode, err := html.Parse(strings.NewReader(current)) + if err != nil { + return nil, fmt.Errorf("failed to parse current HTML: %w", err) + } + shapeTree(currentNode) + + // Parse proposed HTML + proposedNode, err := html.Parse(strings.NewReader(proposed)) + if err != nil { + return nil, fmt.Errorf("failed to parse proposed HTML: %w", err) + } + shapeTree(proposedNode) + + // html.Parse wraps fragments in . Extract the + // element so anchors start at the actual content, matching the client DOM + // inside which has no document wrapper. + currentNode = findBodyOrSelf(currentNode) + proposedNode = findBodyOrSelf(proposedNode) + + // Anchor both trees with island-scoped anchors + idGen := newIslandAnchorGenerator(string(islandID)) + anchorIslandTree(currentNode, idGen) + anchorIslandTree(proposedNode, idGen) + + // Perform the diff + patches := diffIslandTrees(currentNode, proposedNode, string(islandID)) + + // Convert internal patches to output patches + output := make([]Patch, len(patches)) + for idx, p := range patches { + var buf bytes.Buffer + if p.Node != nil { + if err := html.Render(&buf, p.Node); err != nil { + return nil, fmt.Errorf("failed to render patch: %w", err) + } + } else { + if _, err := buf.WriteString(""); err != nil { + return nil, fmt.Errorf("failed to render blank patch: %w", err) + } + } + + output[idx] = Patch{ + Anchor: p.Anchor, + Action: p.Action, + HTML: buf.String(), + IslandID: string(islandID), + } + } + + return output, nil +} + +// anchorIslandTree anchors a tree with island-scoped anchor attributes. +func anchorIslandTree(root *html.Node, id islandAnchorGenerator) { + // Check siblings first + if root.NextSibling != nil { + anchorIslandTree(root.NextSibling, id.inc()) + } + // Then children + if root.FirstChild != nil { + anchorIslandTree(root.FirstChild, id.level()) + } + + // Add anchor if node is relevant and doesn't have one + if nodeRelevant(root) && !hasAnchor(root) { + root.Attr = append(root.Attr, html.Attribute{Key: id.String()}) + } +} + +// diffIslandTrees compares two html Nodes within an island scope and outputs patches. +func diffIslandTrees(current, proposed *html.Node, islandID string) []patch { + d := &differ{} + // Trees are already anchored by caller + return d.compareNodes(current, proposed, "") +} + // patch describes how to modify a dom. type patch struct { Anchor string @@ -182,7 +354,11 @@ func shapeTree(root *html.Node) { func hasAnchor(node *html.Node) bool { for _, a := range node.Attr { - if strings.HasPrefix(a.Key, liveAnchorPrefix) { + // Check for exact prefix matches: _l or _i followed by _ or end of string + if strings.HasPrefix(a.Key, liveAnchorPrefix+"_") || + strings.HasPrefix(a.Key, islandAnchorPrefix+"_") || + a.Key == liveAnchorPrefix || + a.Key == islandAnchorPrefix { return true } } @@ -287,9 +463,34 @@ func (d *differ) generatePatch(node *html.Node, target string, action PatchActio } } +// findBodyOrSelf walks the tree to find the element. If found, +// returns the body node so that island anchors start at content level +// rather than at the document wrapper. Returns the original +// node if no is found (e.g., when input is already a fragment). +func findBodyOrSelf(node *html.Node) *html.Node { + if body := findBody(node); body != nil { + return body + } + return node +} + +// findBody recursively searches for a element in the tree. +func findBody(node *html.Node) *html.Node { + if node.Type == html.ElementNode && node.Data == "body" { + return node + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + if result := findBody(c); result != nil { + return result + } + } + return nil +} + func findAnchor(node *html.Node) string { for _, a := range node.Attr { - if strings.HasPrefix(a.Key, liveAnchorPrefix) { + // Check for both live and island anchors + if strings.HasPrefix(a.Key, liveAnchorPrefix) || strings.HasPrefix(a.Key, islandAnchorPrefix) { return a.Key } } diff --git a/diff_test.go b/diff_test.go index a8de026..6f4841d 100644 --- a/diff_test.go +++ b/diff_test.go @@ -2,6 +2,7 @@ package live import ( "bytes" + "encoding/json" "fmt" "strings" "testing" @@ -467,6 +468,640 @@ func runDiffTest(tt diffTest, t *testing.T) { } } +func TestIslandPatch(t *testing.T) { + patches := []Patch{ + {Anchor: "_l_0_1_0", Action: Replace, HTML: `
Test 1
`}, + {Anchor: "_l_0_1_1", Action: Append, HTML: `
Test 2
`}, + {Anchor: "_l_0_1_2", Action: Prepend, HTML: `
Test 3
`}, + } + + islandID := IslandID("counter-1") + islandPatch := NewIslandPatch(islandID, patches) + + // Verify IslandID is set on wrapper + if islandPatch.IslandID != islandID { + t.Errorf("IslandPatch.IslandID = %q, want %q", islandPatch.IslandID, islandID) + } + + // Verify IslandID is set on each patch + for i, patch := range islandPatch.Patches { + if patch.IslandID != string(islandID) { + t.Errorf("Patch[%d].IslandID = %q, want %q", i, patch.IslandID, islandID) + } + } + + // Verify patch count + if len(islandPatch.Patches) != len(patches) { + t.Errorf("len(IslandPatch.Patches) = %d, want %d", len(islandPatch.Patches), len(patches)) + } +} + +func TestIslandPatchJSON(t *testing.T) { + patches := []Patch{ + {Anchor: "_l_0_1_0", Action: Replace, HTML: `
Test
`, IslandID: "test-island"}, + } + + islandPatch := IslandPatch{ + IslandID: "test-island", + Patches: patches, + } + + // Marshal to JSON + data, err := json.Marshal(islandPatch) + if err != nil { + t.Fatalf("failed to marshal IslandPatch: %v", err) + } + + // Unmarshal back + var unmarshaled IslandPatch + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("failed to unmarshal IslandPatch: %v", err) + } + + // Verify fields + if unmarshaled.IslandID != islandPatch.IslandID { + t.Errorf("IslandID mismatch after unmarshal: got %q, want %q", unmarshaled.IslandID, islandPatch.IslandID) + } + + if len(unmarshaled.Patches) != len(islandPatch.Patches) { + t.Fatalf("Patches count mismatch: got %d, want %d", len(unmarshaled.Patches), len(islandPatch.Patches)) + } + + if unmarshaled.Patches[0].IslandID != islandPatch.Patches[0].IslandID { + t.Errorf("Patch.IslandID mismatch: got %q, want %q", unmarshaled.Patches[0].IslandID, islandPatch.Patches[0].IslandID) + } +} + +func TestPatchJSONWireFormat(t *testing.T) { + // Test that Patch JSON serialization produces the exact wire format + // expected by the TypeScript client. + t.Run("exact key names in JSON output", func(t *testing.T) { + p := Patch{ + Anchor: "_i_test_0", + Action: Replace, + HTML: "5", + IslandID: "test", + } + + data, err := json.Marshal(p) + if err != nil { + t.Fatalf("failed to marshal Patch: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("failed to unmarshal into map: %v", err) + } + + // Assert exact key names match the TypeScript client expectations + expectedKeys := []string{"Anchor", "Action", "HTML", "island_id"} + for _, key := range expectedKeys { + if _, ok := result[key]; !ok { + t.Errorf("expected key %q in JSON output, but it was missing. Got keys: %v", key, result) + } + } + + // Verify no unexpected keys + if len(result) != len(expectedKeys) { + t.Errorf("expected exactly %d keys, got %d. Keys: %v", len(expectedKeys), len(result), result) + } + + // Verify values + if result["Anchor"] != "_i_test_0" { + t.Errorf("Anchor = %v, want %q", result["Anchor"], "_i_test_0") + } + // Action is a uint32 encoded as float64 in JSON + if result["Action"] != float64(Replace) { + t.Errorf("Action = %v, want %v", result["Action"], float64(Replace)) + } + if result["HTML"] != "5" { + t.Errorf("HTML = %v, want %q", result["HTML"], "5") + } + if result["island_id"] != "test" { + t.Errorf("island_id = %v, want %q", result["island_id"], "test") + } + }) + + t.Run("island_id omitted when empty", func(t *testing.T) { + p := Patch{ + Anchor: "_i_test_0", + Action: Replace, + HTML: "5", + } + + data, err := json.Marshal(p) + if err != nil { + t.Fatalf("failed to marshal Patch: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("failed to unmarshal into map: %v", err) + } + + // island_id should be absent when IslandID is empty + if _, ok := result["island_id"]; ok { + t.Error("expected island_id to be omitted when IslandID is empty, but it was present") + } + + // Should have exactly 3 keys: Anchor, Action, HTML + if len(result) != 3 { + t.Errorf("expected exactly 3 keys when IslandID is empty, got %d. Keys: %v", len(result), result) + } + }) +} + +func TestPatchIslandIDField(t *testing.T) { + tests := []struct { + name string + patch Patch + hasIslandID bool + }{ + { + name: "patch with island ID", + patch: Patch{ + Anchor: "_l_0_1_0", + Action: Replace, + HTML: `
Test
`, + IslandID: "test-island-1", + }, + hasIslandID: true, + }, + { + name: "patch without island ID", + patch: Patch{ + Anchor: "_l_0_1_0", + Action: Replace, + HTML: `
Test
`, + }, + hasIslandID: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.patch) + if err != nil { + t.Fatalf("failed to marshal patch: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + + _, hasIslandID := result["island_id"] + if tt.hasIslandID && !hasIslandID { + t.Error("expected island_id field in JSON, but it was missing") + } + if !tt.hasIslandID && hasIslandID { + t.Error("expected no island_id field in JSON, but it was present") + } + + // Unmarshal and verify + var unmarshaled Patch + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("failed to unmarshal into Patch: %v", err) + } + + if unmarshaled.IslandID != tt.patch.IslandID { + t.Errorf("IslandID mismatch: got %q, want %q", unmarshaled.IslandID, tt.patch.IslandID) + } + }) + } +} + +func TestIslandAnchorGenerator(t *testing.T) { + tests := []struct { + name string + islandID string + ops []string + expected string + }{ + { + name: "simple island anchor", + islandID: "counter-1", + ops: []string{}, + expected: "_i_counter-1", + }, + { + name: "island anchor with level", + islandID: "counter-1", + ops: []string{"level"}, + expected: "_i_counter-1_0", + }, + { + name: "island anchor with level and inc", + islandID: "counter-1", + ops: []string{"level", "inc"}, + expected: "_i_counter-1_1", + }, + { + name: "island anchor nested path", + islandID: "counter-1", + ops: []string{"level", "inc", "level", "inc", "inc"}, + expected: "_i_counter-1_1_2", + }, + { + name: "complex nested path", + islandID: "form-123", + ops: []string{"level", "level", "inc"}, + expected: "_i_form-123_0_1", + }, + { + name: "empty island ID", + islandID: "", + ops: []string{"level"}, + expected: "_i__0", + }, + { + name: "island ID with special chars", + islandID: "user-widget_v2", + ops: []string{"level", "inc"}, + expected: "_i_user-widget_v2_1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := newIslandAnchorGenerator(tt.islandID) + for _, op := range tt.ops { + switch op { + case "level": + gen = gen.level() + case "inc": + gen = gen.inc() + } + } + result := gen.String() + if result != tt.expected { + t.Errorf("islandAnchorGenerator.String() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestIslandAnchorUniqueness(t *testing.T) { + // Test that same path in different islands produces unique anchors + island1 := newIslandAnchorGenerator("counter-1") + island2 := newIslandAnchorGenerator("counter-2") + + // Apply same operations to both + island1 = island1.level().inc().level() + island2 = island2.level().inc().level() + + anchor1 := island1.String() + anchor2 := island2.String() + + if anchor1 == anchor2 { + t.Errorf("anchors should be unique across islands, but both are %q", anchor1) + } + + expectedAnchor1 := "_i_counter-1_1_0" + expectedAnchor2 := "_i_counter-2_1_0" + + if anchor1 != expectedAnchor1 { + t.Errorf("island1 anchor = %q, want %q", anchor1, expectedAnchor1) + } + + if anchor2 != expectedAnchor2 { + t.Errorf("island2 anchor = %q, want %q", anchor2, expectedAnchor2) + } +} + +func TestHasAnchorBackwardCompatibility(t *testing.T) { + tests := []struct { + name string + attrs []html.Attribute + expected bool + }{ + { + name: "node with legacy anchor", + attrs: []html.Attribute{ + {Key: "_l_0_1_0", Val: ""}, + }, + expected: true, + }, + { + name: "node with island anchor", + attrs: []html.Attribute{ + {Key: "_i_counter-1_0_1_0", Val: ""}, + }, + expected: true, + }, + { + name: "node with no anchor", + attrs: []html.Attribute{ + {Key: "class", Val: "container"}, + {Key: "id", Val: "main"}, + }, + expected: false, + }, + { + name: "node with both anchor types", + attrs: []html.Attribute{ + {Key: "_l_0_1_0", Val: ""}, + {Key: "_i_counter-1_0_1_0", Val: ""}, + }, + expected: true, + }, + { + name: "node with similar but not anchor attribute", + attrs: []html.Attribute{ + {Key: "_legacy", Val: ""}, + {Key: "_island", Val: ""}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + node := &html.Node{ + Type: html.ElementNode, + Data: "div", + Attr: tt.attrs, + } + result := hasAnchor(node) + if result != tt.expected { + t.Errorf("hasAnchor() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestIslandAnchorGeneratorDeterministic(t *testing.T) { + // Verify that the same operations produce the same anchor consistently + gen1 := newIslandAnchorGenerator("test-island") + gen1 = gen1.level().inc().inc().level() + + gen2 := newIslandAnchorGenerator("test-island") + gen2 = gen2.level().inc().inc().level() + + anchor1 := gen1.String() + anchor2 := gen2.String() + + if anchor1 != anchor2 { + t.Errorf("anchors should be deterministic: gen1=%q, gen2=%q", anchor1, anchor2) + } + + expected := "_i_test-island_2_0" + if anchor1 != expected { + t.Errorf("anchor = %q, want %q", anchor1, expected) + } +} + +func TestDiffIslandMultipleIslands(t *testing.T) { + t.Run("independent island diffs", func(t *testing.T) { + // Island 1 + patches1, err := DiffIsland("counter-1", "
0
", "
1
") + if err != nil { + t.Fatalf("DiffIsland(counter-1) error = %v", err) + } + + // Island 2 + patches2, err := DiffIsland("counter-2", "
0
", "
2
") + if err != nil { + t.Fatalf("DiffIsland(counter-2) error = %v", err) + } + + // Both should generate patches + if len(patches1) == 0 { + t.Error("island 1 should generate patches") + } + if len(patches2) == 0 { + t.Error("island 2 should generate patches") + } + + // Patches should have correct island IDs + for _, p := range patches1 { + if p.IslandID != "counter-1" { + t.Errorf("island 1 patch has IslandID=%q, want counter-1", p.IslandID) + } + } + for _, p := range patches2 { + if p.IslandID != "counter-2" { + t.Errorf("island 2 patch has IslandID=%q, want counter-2", p.IslandID) + } + } + + // Patches should have different anchors + if patches1[0].Anchor == patches2[0].Anchor { + t.Error("different islands should have different anchors") + } + }) +} + +func TestDiffIslandAnchorScope(t *testing.T) { + t.Run("island anchors use island prefix", func(t *testing.T) { + patches, err := DiffIsland("my-island", "
A
", "
B
") + if err != nil { + t.Fatalf("DiffIsland() error = %v", err) + } + + for _, p := range patches { + if !strings.HasPrefix(p.Anchor, islandAnchorPrefix) { + t.Errorf("anchor %q should start with island prefix %q", p.Anchor, islandAnchorPrefix) + } + if !strings.Contains(p.Anchor, "my-island") { + t.Errorf("anchor %q should contain island ID", p.Anchor) + } + } + }) + + t.Run("legacy diff uses live prefix", func(t *testing.T) { + root, _ := html.Parse(strings.NewReader("
A
")) + proposed, _ := html.Parse(strings.NewReader("
B
")) + shapeTree(root) + shapeTree(proposed) + + patches, err := Diff(root, proposed) + if err != nil { + t.Fatalf("Diff() error = %v", err) + } + + for _, p := range patches { + if !strings.HasPrefix(p.Anchor, liveAnchorPrefix) { + t.Errorf("legacy anchor %q should start with live prefix %q", p.Anchor, liveAnchorPrefix) + } + } + }) +} + +func TestAnchorIslandTree(t *testing.T) { + t.Run("anchors all nodes with island ID", func(t *testing.T) { + root, err := html.Parse(strings.NewReader("
test
")) + if err != nil { + t.Fatal(err) + } + shapeTree(root) + + gen := newIslandAnchorGenerator("test-id") + anchorIslandTree(root, gen) + + // Count anchored nodes + anchorCount := 0 + var countAnchors func(*html.Node) + countAnchors = func(n *html.Node) { + if n.Type == html.ElementNode { + for _, attr := range n.Attr { + if strings.HasPrefix(attr.Key, "_i_test-id") { + anchorCount++ + break + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + countAnchors(c) + } + } + countAnchors(root) + + if anchorCount == 0 { + t.Error("should anchor at least one node") + } + }) + + t.Run("does not duplicate anchors", func(t *testing.T) { + root, err := html.Parse(strings.NewReader("
test
")) + if err != nil { + t.Fatal(err) + } + shapeTree(root) + + gen := newIslandAnchorGenerator("test-id") + + // Anchor twice + anchorIslandTree(root, gen) + anchorIslandTree(root, gen) + + // Count anchors on a single node + var checkNode func(*html.Node) bool + checkNode = func(n *html.Node) bool { + if n.Type == html.ElementNode && n.Data == "div" { + anchorCount := 0 + for _, attr := range n.Attr { + if strings.HasPrefix(attr.Key, "_i_") { + anchorCount++ + } + } + return anchorCount == 1 + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if checkNode(c) { + return true + } + } + return false + } + + if !checkNode(root) { + t.Error("should not duplicate anchors when called multiple times") + } + }) +} + +func TestDiffIslandAnchorsMatchClientDOM(t *testing.T) { + t.Run("anchors do not include html/body wrapper prefix", func(t *testing.T) { + // DiffIsland uses html.Parse which wraps fragments in . + // Anchors must NOT reference the wrapper elements because the client + // DOM inside has no such wrappers. + patches, err := DiffIsland("test", "
A
", "
B
") + if err != nil { + t.Fatalf("DiffIsland error: %v", err) + } + + if len(patches) == 0 { + t.Fatal("expected patches for content change") + } + + for _, p := range patches { + // The anchor should start directly with content-level elements. + // With body stripping, the body itself gets _i_test (no numeric suffix), + // and its first child div gets _i_test_0. Anchors should reference + // content elements, not wrapper elements like _i_test_0_1 (old body path). + if !strings.HasPrefix(p.Anchor, "_i_test") { + t.Errorf("anchor %q should start with _i_test", p.Anchor) + } + // The anchor should be short (content-level), not have the deep + // wrapper path that html.Parse would create without body stripping + parts := strings.Split(p.Anchor, "_") + // _i_test_0 splits to ["", "i", "test", "0"] = 4 parts + // Old broken: _i_test_0_1_0 splits to 6+ parts + if len(parts) > 5 { + t.Errorf("anchor %q has too many segments, likely includes html/body wrapper path", p.Anchor) + } + } + }) + + t.Run("initial mount from empty produces valid patches", func(t *testing.T) { + // Simulates server initial mount: previousHTML="" (no prior state), + // proposed is the rendered island content. + patches, err := DiffIsland("counter-1", "", `
0
`) + if err != nil { + t.Fatalf("DiffIsland error: %v", err) + } + + if len(patches) == 0 { + t.Fatal("initial mount should produce patches") + } + + // All patches should have the correct island ID + for _, p := range patches { + if p.IslandID != "counter-1" { + t.Errorf("patch IslandID = %q, want counter-1", p.IslandID) + } + } + + // Patch HTML should contain the rendered content with anchors + found := false + for _, p := range patches { + if strings.Contains(p.HTML, "count") { + found = true + } + } + if !found { + t.Error("initial mount patches should contain the rendered content") + } + }) + + t.Run("subsequent diff anchors match initial patch HTML anchors", func(t *testing.T) { + // First render: empty -> initial content + initialPatches, err := DiffIsland("counter-1", "", `
0
`) + if err != nil { + t.Fatalf("initial DiffIsland error: %v", err) + } + if len(initialPatches) == 0 { + t.Fatal("initial mount should produce patches") + } + + // The initial patch HTML contains anchor attributes that the client + // will set via innerHTML. Extract an anchor from the initial HTML. + initialHTML := initialPatches[0].HTML + + // Second render: same content -> updated content + // The "current" should be the raw render output (without anchors), + // matching what the server stores in lastRenderedHTML. + updatePatches, err := DiffIsland("counter-1", `
0
`, `
1
`) + if err != nil { + t.Fatalf("update DiffIsland error: %v", err) + } + if len(updatePatches) == 0 { + t.Fatal("update should produce patches") + } + + // The update patch anchor should exist as an attribute in the initial + // patch HTML. This validates that the client DOM (set from initial HTML) + // will contain the anchor the server references in the update patch. + updateAnchor := updatePatches[0].Anchor + if !strings.Contains(initialHTML, updateAnchor) { + t.Errorf("update anchor %q not found in initial patch HTML %q\n"+ + "This means the client DOM won't have the anchor the server references", + updateAnchor, initialHTML) + } + }) +} + var testPage string = ` diff --git a/docs/plans/v2-client-server-integration.md b/docs/plans/v2-client-server-integration.md new file mode 100644 index 0000000..1f82ed7 --- /dev/null +++ b/docs/plans/v2-client-server-integration.md @@ -0,0 +1,264 @@ +# Implementation Plan: v2 Client-Server Integration + +Fix the critical gaps between the Go server and TypeScript client library, then connect the real v2 client library (`auto.js`) to the counter example, replacing the hand-written `custom-island.js`. + +## Context + +**Research Document**: `docs/research/2026-02-22-v2-branch-state-and-testing-gaps.md` + +**Key Files**: +- `diff.go:124-139` - Patch struct with no json tags (fragile wire format) +- `http.go` - HTTP helpers (needs shared session ID extraction) +- `transport_sse.go:391-405` - Private `getSessionIDFromRequest()` (needs exporting) +- `web/src/transport/negotiator.ts:142-148` - Negotiator fallback bug +- `web/src/transport/websocket.ts:127` - Unhandled reconnection promise +- `web/src/transport/sse.ts:136` - Same unhandled reconnection promise +- `web/src/island.ts` - LiveIsland custom element (missing event wiring) +- `web/src/events.ts` - EventWiring (exists, tested, never called by LiveIsland) +- `examples/counter/main.go` - Counter example server +- `examples/counter/index.html` - Counter HTML template +- `examples/counter/custom-island.js` - Hand-written client (to be removed) +- `javascript.go` - `live.Javascript{}` handler for serving auto.js + +**Architectural Notes**: +- Wire format is JSON. Patch struct fields serialize as capitalized (`Anchor`, `Action`, `HTML`) due to missing json tags - accidental compatibility with TypeScript client. +- Session IDs are incompatible: client generates UUIDs in `live_session` cookie, server ignores cookie and generates timestamp-based IDs. Reconnection cannot restore state. +- 8 negotiator tests fail due to unhandled promise rejections from WebSocket reconnection attempts during transport fallback. +- LiveIsland custom element registers with ConnectionManager and applies patches, but never calls `wireIslandEvents()` to attach `live-click`/`live-submit` handlers. Buttons won't work. +- Counter example SSE endpoints (`/sse`, `/sse/post`) don't match client defaults (`/live/sse`, `/live/post`). + +**Functional Requirements** (EARS notation): +- When a `Patch` struct is JSON-serialized, the system shall produce field names `Anchor`, `Action`, `HTML`, and `island_id` matching the TypeScript client expectations. +- When a WebSocket or SSE connection is established, the system shall read the session ID from the client's `live_session` cookie, falling back to generating a new ID if no cookie exists. +- When a transport reconnection attempt fails after explicit `close()`, the system shall suppress the rejection to prevent unhandled promise errors. +- When a `` element connects, the system shall wire `live-click` and other event handlers within the island's DOM boundary. +- When a patch is applied to a ``, the system shall re-wire event handlers on the new DOM content. + +## Batch Size + +| Metric | Count | Rating | +|--------|-------|--------| +| Tasks | 11 | Large | +| Files | 14 | Large | +| Stages | 3 | Medium | + +**Overall: Large** + +## Execution Stages + +### Stage 1: Fix Foundational Issues + +All tasks in this stage are independent and can run in parallel. + +#### Test Creation Phase (parallel) + +- T-test-1a: Write JSON serialization contract test for Patch struct (diff_test.go) +- T-test-1b: Write GetSessionIDFromRequest tests (http_test.go) + +#### Implementation Phase (parallel, depends on Test Creation Phase) + +- T-impl-1a: Add explicit JSON tags to Patch struct (diff.go) +- T-impl-1b: Export GetSessionIDFromRequest to http.go (http.go, transport_sse.go) +- T-impl-1c: Fix unhandled reconnection promises in transports (websocket.ts, sse.ts) +- T-impl-1d: Fix negotiator test mocks (negotiator.spec.ts) +- T-impl-1e: Wire events in LiveIsland custom element (island.ts) + +### Stage 2: Build Client Bundle (depends on Stage 1) + +#### Implementation Phase + +- T-impl-2a: Rebuild auto.js bundle (web/browser/auto.js) + +### Stage 3: Integrate Counter Example (depends on Stage 2) + +#### Implementation Phase + +- T-impl-3a: Update counter example to use auto.js, cookie sessions, and correct endpoints (main.go, index.html) +- T-impl-3b: Delete custom-island.js + +## Task List + +### Wire Format Contract + +- [x] Add explicit JSON tags to Patch struct (`diff.go`) [Stage 1] + - Files: `diff.go` (modifies) + - Change `Anchor string` to `Anchor string \`json:"Anchor"\`` + - Change `Action PatchAction` to `Action PatchAction \`json:"Action"\`` + - Change `HTML string` to `HTML string \`json:"HTML"\`` + - Keep existing `IslandID string \`json:"island_id,omitempty"\`` unchanged + - Tags MUST use capitalized names to match TypeScript client (`web/src/transport/message.ts:27-29`) + +- [x] Add JSON serialization contract test (`diff_test.go`) [Stage 1] + - Files: `diff_test.go` (modifies) + - Marshal a `Patch` to JSON, unmarshal into `map[string]interface{}` + - Assert exact key names: `"Anchor"`, `"Action"`, `"HTML"`, `"island_id"` + - Assert `"island_id"` is omitted when `IslandID` is empty + - Follow pattern of existing `TestIslandPatchJSON` test + +### Session ID Handling + +- [x] Export `GetSessionIDFromRequest` to `http.go` (`http.go`, `transport_sse.go`) [Stage 1] + - Files: `http.go` (modifies), `transport_sse.go` (modifies) + - Move `getSessionIDFromRequest` from `transport_sse.go:391-405` to `http.go` + - Rename to `GetSessionIDFromRequest` (exported) + - Update call sites in `transport_sse.go` (lines 311, 343) + - Remove old private function from `transport_sse.go` + +- [x] Add tests for `GetSessionIDFromRequest` (`http_test.go`) [Stage 1] + - Files: `http_test.go` (creates) + - Test cookie extraction (`live_session` cookie) + - Test header extraction (`X-Live-Session`) + - Test cookie takes priority over header + - Test empty return when neither exists + +### Transport Negotiation Fix + +- [x] Add `.catch()` to reconnection `connect()` in WebSocketTransport (`web/src/transport/websocket.ts`) [Stage 1] + - Files: `web/src/transport/websocket.ts` (modifies) + - At line 127, change `this.connect();` to `this.connect().catch(() => {});` + - Prevents unhandled promise rejections when `close()` is called during pending reconnection + +- [x] Add `.catch()` to reconnection `connect()` in SSETransport (`web/src/transport/sse.ts`) [Stage 1] + - Files: `web/src/transport/sse.ts` (modifies) + - At line 136, change `this.connect();` to `this.connect().catch(() => {});` + - Same pattern as WebSocket fix + +- [x] Fix negotiator test mocks for timeout scenarios (`web/src/transport/negotiator.spec.ts`) [Stage 1] + - Files: `web/src/transport/negotiator.spec.ts` (modifies) + - "should timeout and fallback" test (lines 248-274): Replace broken `MockWebSocket.prototype.constructor` override with a full global `WebSocket` replacement class that never fires `open` + - "should use custom timeout value" test (lines 276-308): Same fix for both WebSocket and EventSource mocks + - `prototype.constructor` override does NOT change `new` behavior in JavaScript - must replace the global constructor entirely + +### LiveIsland Event Wiring + +- [x] Wire `live-click` and other events in LiveIsland (`web/src/island.ts`) [Stage 1] + - Files: `web/src/island.ts` (modifies) + - Import `wireIslandEvents` from `./events` + - Add `private eventCleanup: (() => void) | null = null;` + - In `connectedCallback()`, after registration: `this.eventCleanup = wireIslandEvents(this, this.islandId);` + - In `handlePatch()`, after applying patches: clean up old wiring, re-wire new DOM + - In `disconnectedCallback()`: call cleanup function + - Without this, `live-click` buttons in the counter example will render but do nothing + +### Client Bundle + +- [x] Rebuild auto.js bundle [Stage 2] *(build step, not code edit)* + - Files: `web/browser/auto.js` (modifies), `web/browser/auto.js.map` (modifies) + - Shell command: `cd web && npm run build` + - Must happen AFTER island.ts changes (Stage 1) but BEFORE Go compilation (Stage 3) + - `javascript.go` embeds `web/browser/auto.js` at compile time + +### Counter Example Integration + +- [x] Update counter example to use auto.js and cookie-based sessions (`examples/counter/main.go`, `examples/counter/index.html`) [Stage 3] + - Files: `examples/counter/main.go` (modifies), `examples/counter/index.html` (modifies) + - **main.go changes:** + - Replace `/custom-island.js` handler with `http.Handle("/live.js", live.Javascript{})` + - Change SSE endpoint from `/sse` to `/live/sse` (matches client default) + - Change SSE POST from `/sse/post` to `/live/post` (matches client default) + - Note: `sseFactory.HandlePost` is path-agnostic (it processes the request body, not the URL path), so changing endpoints is safe + - Replace session ID generation in both WS and SSE handlers with: + ```go + sessionID := live.SessionID(live.GetSessionIDFromRequest(r)) + if sessionID == "" { + sessionID = live.SessionID(fmt.Sprintf("session-%d", time.Now().UnixNano())) + } + ``` + - **index.html changes:** + - Change `` to `` + +- [x] Delete custom-island.js (`examples/counter/custom-island.js`) [Stage 3] + - Files: `examples/counter/custom-island.js` (deletes) + - Replaced by auto.js (the v2 client library) + +## Acceptance Criteria + +```gherkin +Feature: v2 Client-Server Integration + + Scenario: Patch struct JSON serialization matches wire format contract + Given a Patch struct with Anchor "_i_test_0", Action Replace, HTML "5" + When the struct is JSON-marshaled + Then the JSON contains keys "Anchor", "Action", "HTML" with correct values + And "island_id" is omitted when IslandID is empty + + Scenario: Server reads session ID from client cookie on WebSocket upgrade + Given a client with "live_session" cookie set to "abc-123" + When the client opens a WebSocket connection + Then the server uses "abc-123" as the session ID + + Scenario: Server generates session ID when no cookie exists + Given a client with no "live_session" cookie + When the client opens a WebSocket connection + Then the server generates a new session ID + + Scenario: Transport negotiator falls back to SSE when WebSocket fails + Given WebSocket connections are blocked + When the client negotiates a transport + Then the negotiator falls back to SSE transport + And no unhandled promise rejections occur + + Scenario: Transport negotiator handles connection timeout + Given WebSocket connection hangs without responding + When the negotiator timeout expires + Then the negotiator falls back to the next transport + And the hanging transport is closed + + Scenario: LiveIsland wires click events after mount + Given a with a + + ``` + +## Acceptance Criteria + +~~~gherkin +Feature: Clock example + + Scenario: Multiple clocks display different timezones + Given the clock example server is running + When a browser connects to the page + Then four clock islands render (UTC, New York, London, Tokyo) + And each displays the current time in its respective timezone + + Scenario: Clock updates every second + Given a browser is connected to the clock example + When 2 seconds elapse + Then the displayed time has changed at least once + + Scenario: Clock stops on disconnect + Given a browser is connected to the clock example + When the WebSocket connection closes + Then the tick timer is cancelled and no further renders occur + +Feature: Hooks example + + Scenario: Error event received by client hook + Given the hooks example server is running + And a browser is connected with the "err" hook mounted + When the user clicks "Make a problem" + Then the server returns an error + And the client hook receives the error event + And an alert displays "Error from server: something went wrong" + + Scenario: Error does not crash the server + Given the hooks example server is running + When the user clicks "Make a problem" multiple times + Then each click results in an error event sent to the client + And the server continues to handle events normally +~~~ + +**Source**: Generated from plan context + +## Implementation Notes + +- **Multiple clock instances**: Like counter, clock uses multiple instances of the same island type with different props. Each clock gets a timezone and label via props. The subscribe handler looks up the clock config by island ID to determine props. The grid layout uses CSS Grid for responsive display. +- **Hooks error handling**: Part 1 implements engine-level error handling. The event loop just logs errors; the engine sends error events automatically via the island's error handler (default or custom). +- **Testing clock timing**: Use short `WithEventDelay` (50ms) in tests and assert state changes within 200ms. Do not use 1-second delays in tests. +- **Hooks window.Hooks**: The v2 client auto-discovers hooks from `window.Hooks` via `autoRegisterHooks()` in `hooks.ts`. IMPORTANT: The inline `` + +- [x] Create todo island template (`examples/forms/todo.html`) [Stage 1] + - Files: `examples/forms/todo.html` (creates) + - Form with `id="todo-form" live-change="validate" live-submit="save"` + - Error display: `{{ if index .Errors "message" }}
{{ index .Errors "message" }}
{{ end }}` + - Text input: `` + - Submit button: `` + - Task list: `{{ range .Tasks }}` with checkbox `` and name with conditional strikethrough + +- [x] Create prefill island template (`examples/forms/prefill.html`) [Stage 1] + - Files: `examples/forms/prefill.html` (creates) + - Form with `id="prefill-form" live-change="validate" live-submit="save"` + - Validation: `{{ if .Validation }}
{{ .Validation }}
{{ end }}` + - Name input: `` + - Age input: `` + - Submit button: `` + - Current values display: `
Current: {{ .Name }}, age {{ .Age }}
` + +### Chat Example (merged chat + cluster) + +- [x] Write chat integration tests (`examples/chat/main_test.go`) [Stage 1, Test Creation Phase] + - Files: `examples/chat/main_test.go` (creates) + - **TestChatIsland_NewMessage**: Create chat island, call "newmessage" self handler with Message data, verify state.Messages contains the message + - **TestChatIsland_NewMessageAppend**: Call "newmessage" twice, verify state only contains the latest message (single-message for append mode) + - **TestChatIsland_Mount**: Mount chat island with Props{"user": "session-1"}, verify initial state has welcome message + - Helper: create registry, engine, mock transport, session — same pattern as `engine_test.go` + +- [x] Create chat island definition and HTTP server (`examples/chat/main.go`) [Stage 1] + - Files: `examples/chat/main.go` (creates) + - **State**: + ```go + type Message struct { + ID string + User string + Msg string + } + type ChatState struct { + Messages []Message + } + ``` + - **NewChatIsland()**: mount returns `ChatState{Messages: []Message{welcomeMsg}}` where welcomeMsg uses `props.String("user")` as user. Event handlers: + - `"send"`: validates message, returns state unchanged (broadcast handles display for all clients) + - `"newmessage"`: receives Message via `data`, sets `state.Messages = []Message{msg}` (single message for append mode), returns state + - Registration: `live.RegisterIsland("chat", NewChatIsland)` + - **Broadcast pattern — framework `Broadcast` with `LocalTransport`**: + - Uses the framework-level `live.Broadcast` (from Part 1) instead of example-local abstractions + - `BroadcastToIslandType` sends events directly to the client transport, NOT through `HandleSelf`. The `Broadcast` uses `BroadcastSelfToIslandType` instead, which routes through handlers so state is updated. + - **Multi-server demo**: The example runs multiple HTTP servers on different ports (`:8080` and `:8081`) in the same process, each with their own engine, sharing one `Broadcast`: + ```go + func main() { + ctx := context.Background() + broadcast := live.NewBroadcast(ctx, live.NewLocalTransport()) + // Start server 1 on :8080 + go startServer(":8080", broadcast) + // Start server 2 on :8081 + startServer(":8081", broadcast) + } + ``` + - Each `startServer` creates its own engine and subscribes to the broadcast: + ```go + func startServer(addr string, broadcast *live.Broadcast) { + engine := live.NewIslandEngine(ctx, registry, store) + broadcast.Subscribe("chat-room", "chat", engine) + // ... transport setup ... + } + ``` + - The "send" event handler validates the message and returns state unchanged. The event loop publishes to the broadcast: + ```go + // In the event loop, after RouteEvent: + if event.T == "send" { + broadcast.Publish(ctx, "chat-room", live.Event{ + T: "newmessage", + SelfData: Message{ID: id, User: user, Msg: msg}, + }) + } + ``` + - The `Broadcast` delivers the event via `BroadcastSelfToIslandType("chat", event)` to all subscribed engines, routing through each island's "newmessage" `HandleSelf` handler + - HTTP server: `startServer(addr string, broadcast *live.Broadcast)` function with engine setup, WS/SSE transport + - Subscribe handler: `engine.MountIsland(sessionID, islandID, "chat", Props{"user": string(sessionID)})` + +- [x] Create chat page template (`examples/chat/index.html`) [Stage 1] + - Files: `examples/chat/index.html` (creates) + - Single `` + - Description: explains cluster demo — open two browser tabs on different ports (8080 and 8081) to see cross-server messaging + - JavaScript hook for "chat" (form clearing on send): + ```javascript + window.Hooks = { + "chat": { + mounted: function() { + this.el.addEventListener("submit", function() { + setTimeout(function() { + document.querySelector("[name='message']").value = ""; + }, 100); + }); + } + } + }; + ``` + - CSS for .messages (scrollable), .message (flex row), .user (bold), form layout + - `` + +- [x] Create chat island template (`examples/chat/chat.html`) [Stage 1] + - Files: `examples/chat/chat.html` (creates) + - Messages container: `
{{ range .Messages }}
{{ .User }}: {{ .Msg }}
{{ end }}
` + - Form: `
` + +### Alpine Example + +- [x] Write alpine integration tests (`examples/alpine/main_test.go`) [Stage 1, Test Creation Phase] + - Files: `examples/alpine/main_test.go` (creates) + - **TestAlpineIsland_Suggest**: Create island, call "suggest" with search "go", verify Suggestions contains matching items + - **TestAlpineIsland_SuggestNoMatch**: Call "suggest" with search "xyz", verify Suggestions is empty + - **TestAlpineIsland_Selected**: Call "selected" with valid item ID, verify item added to Selected + - **TestAlpineIsland_SelectedNoDuplicate**: Select same item twice, verify Selected has it only once + - Helper: create registry, engine, mock transport, session + +- [x] Create alpine island definition and HTTP server (`examples/alpine/main.go`) [Stage 1] + - Files: `examples/alpine/main.go` (creates) + - **State**: + ```go + type Item struct { + ID string + Name string + } + func (i Item) Match(search string) bool { + return strings.Contains(strings.ToLower(i.Name), strings.ToLower(search)) + } + type AlpineState struct { + Items []Item + Suggestions []Item + Selected []Item + } + ``` + - **NewAlpineIsland()**: mount returns state with predefined items list (e.g., programming languages: Go, JavaScript, Python, Rust, TypeScript). Event handlers: + - `"suggest"`: extract `params.String("search")`, filter items by match, set `state.Suggestions` + - `"selected"`: extract `params.String("id")`, find item, add to `state.Selected` if not already present (dedup by ID) + - `"submit"`: return state unchanged + - Registration: `live.RegisterIsland("alpine", NewAlpineIsland)` + - HTTP server: same pattern as counter + - Subscribe handler: mount with empty props + +- [x] Create alpine page template (`examples/alpine/index.html`) [Stage 1] + - Files: `examples/alpine/index.html` (creates) + - Alpine.js v3 via CDN: `` + - Single `` + - Alpine `autocomplete()` function inline: + ```javascript + function autocomplete() { + return { + isOpen: false, + open() { this.isOpen = true; }, + close() { this.isOpen = false; } + }; + } + ``` + - CSS for autocomplete dropdown, suggestions list, selected items + - `` (before Alpine to ensure live.js loads first) + - Note: Use Alpine.js v3 `@click.outside` instead of v2 `@click.away` + +- [x] Create alpine island template (`examples/alpine/alpine.html`) [Stage 1] + - Files: `examples/alpine/alpine.html` (creates) + - Wrapper: `
` + - Form: `
` + - Input: `` + - Suggestions dropdown: `
    {{ range .Suggestions }}
  • {{ .Name }}
  • {{ end }}
` + - Selected items: `{{ if .Selected }}

Selected:

    {{ range .Selected }}
  • {{ .Name }}
  • {{ end }}
{{ end }}` + +## Acceptance Criteria + +~~~gherkin +Feature: Forms example — Todo list + + Scenario: Add a task via form submission + Given the forms example is running and the todo island is mounted + When the user types "Buy groceries" in the task input + And submits the form + Then "Buy groceries" appears in the task list + + Scenario: Validate task input on change + Given the todo island is mounted + When the user blurs the task input while it is empty + Then a validation error message is displayed + + Scenario: Toggle task completion + Given a task "Buy groceries" exists in the list + When the user clicks the checkbox next to it + Then the task is marked as complete with strikethrough styling + +Feature: Forms example — Prefill form + + Scenario: Form loads with initial values + Given the prefill island is mounted with props name="Test User" age=35 + Then the name input shows "Test User" + And the age input shows "35" + + Scenario: Update prefill values + Given the prefill form is displayed + When the user changes name to "New Name" and submits + Then the displayed values update to "New Name" + +Feature: Chat example + + Scenario: Send a message within same server + Given two browser tabs are connected to the chat example on port 8080 + When Tab 1 sends "Hello everyone" + Then both tabs display the message "Hello everyone" + + Scenario: Cross-server message sync + Given one browser is connected to the chat on port 8080 + And another browser is connected to the chat on port 8081 + When the first browser sends "Hello from 8080" + Then the second browser also displays "Hello from 8080" + + Scenario: Messages append without replacing + Given a chat with existing messages + When a new message arrives via broadcast + Then the new message is appended to the existing messages + And previous messages remain visible + +Feature: Alpine example — Autocomplete + + Scenario: Suggestions appear on typing + Given the alpine example is running + When the user types "go" in the search input + Then a dropdown shows matching suggestions (e.g., "Go") + + Scenario: Select a suggestion + Given suggestions are showing + When the user clicks "Go" + Then "Go" appears in the selected items list + And the suggestions dropdown closes + + Scenario: No duplicate selections + Given "Go" is already in the selected list + When the user searches and clicks "Go" again + Then the selected list still shows "Go" only once +~~~ + +**Source**: Generated from plan context + +## Implementation Notes + +- **Forms ID generation**: No `live.NewID()` in v2. Use `fmt.Sprintf("task-%d", state.NextID)` with incrementing counter. +- **Forms error map access**: Use `{{ index .Errors "message" }}` in Go templates for map access. +- **Chat Broadcast pattern**: Uses the framework-level `live.Broadcast` with `live.LocalTransport` (from Part 1). `BroadcastToIslandType` sends directly to client transport — but `Broadcast` uses `BroadcastSelfToIslandType` which routes through `HandleSelf` handlers, so server-side state is updated. No example-local broadcast abstraction needed. +- **Chat multi-server demo**: The example runs two HTTP servers on different ports (`:8080`, `:8081`) sharing a single `live.Broadcast`. This demonstrates cross-server message sync. In production, users implement the `BroadcastTransport` interface for Redis, NATS, or similar. +- **Chat live-update="append"**: The diff engine produces Append patches when this attribute is present. The "newmessage" handler sets `state.Messages = []Message{newMsg}` (single message), and the re-rendered HTML contains only that message, which gets appended to the existing DOM. +- **Chat reconnect caveat**: On reconnect, the state store only has the last single message, not full history. The mount handler provides a welcome message. Full message history would require external storage (out of scope for this example). +- **Alpine.js v3 vs v2**: Use `@click.outside` instead of `@click.away`. Alpine v3 is loaded via CDN with `defer`. +- **Alpine DOM reconciliation**: When v2 patches replace elements with `x-data`, Alpine.js v3 re-initializes from the attribute. Client-side state (like `isOpen`) resets on re-render, which is acceptable for the autocomplete pattern. +- **Form state preservation**: The v2 client's `Forms.dehydrate()`/`Forms.hydrate()` preserves input focus and values across re-renders. Forms must have unique `id` attributes. + +## Refs + +- `docs/research/2026-02-25-v2-examples-porting.md` +- `docs/plans/v2-examples-part-1.md` — Framework API changes +- `docs/plans/v2-examples-part-2.md` — Clock + Hooks examples diff --git a/docs/plans/v2-feature-parity.md b/docs/plans/v2-feature-parity.md new file mode 100644 index 0000000..ff72dd8 --- /dev/null +++ b/docs/plans/v2-feature-parity.md @@ -0,0 +1,446 @@ +# Implementation Plan: V2 Feature Parity with Master README + +Implement all features documented in the master branch README that are missing from V2, plus full test coverage for existing gaps. This covers client-side event directives, server-side navigation/upload APIs, new examples, and test coverage improvements. + +## Context + +**Research Document**: `docs/research/2026-03-01-v2-feature-gaps-and-test-coverage.md` + +**Key Files**: +- `web/src/events.ts` - Client event wiring (EventWiring class, Debouncer, wireStandardEvent/wireKeyEvent patterns) +- `web/src/connection.ts` - ConnectionManager singleton, message routing via routeMessage() +- `web/src/event.ts` - Connection CSS class constants and EventDispatch methods (ClassConnected, ClassDisconnected, ClassError; disconnected(), reconnected(), error() static methods that already manage body classes) +- `web/src/transport/message.ts` - TransportMessage interface, MessageType (already includes Redirect, Params) +- `web/src/forms.ts` - Form utilities with basic XHR file upload +- `island.go` - Island definition, handlers, IslandConfig pattern +- `instance.go` - IslandInstance runtime, CallEvent, CallSelf +- `session.go` - Session event routing, routeToIsland +- `engine.go` - IslandEngine orchestration, RouteEvent, BroadcastToIsland +- `context.go` - Context keys for session, island, engine +- `event.go` - Event types including EventParams, EventRedirect (already defined) +- `transport_endpoints.go` - HTTP handler wrappers for WebSocket/SSE +- `javascript.go` - JS/source map serving handlers + +**Architectural Notes**: +- V2 uses island-scoped architecture where each island has isolated state and lifecycle +- EventWiring pattern: query elements within island, attach listeners, push cleanup functions +- Island config pattern: `IslandConfig func(i *Island) error` with `With*()` builders +- Context enrichment: engine adds sessionID, islandID, engine ref, selfEventQueue to context before routing +- Event wire format: `Event{T: "type", Island: "id", Data: json.RawMessage}` +- MessageType constants for Params and Redirect already exist in both Go and TypeScript +- `EventDispatch` in `event.ts` already has `disconnected()`, `reconnected()`, `error()` static methods that manage CSS classes on `document.body` -- ConnectionManager should call these existing methods rather than duplicating logic + +**Functional Requirements** (EARS notation): +- When a user presses a key and an element has `live-window-keyup`, the system shall fire a window-level keyboard event routed to the declaring island +- When a user clicks an element with `live-window-focus`, the system shall fire a window-level focus event routed to the declaring island +- When a user clicks an anchor with `live-patch`, the system shall update the browser URL and send a params event to the server +- When an event handler calls `PatchURL(ctx, values)`, the system shall send an EventParams event to the client, which updates the browser URL via history.pushState +- When an event handler calls `Redirect(ctx, url)`, the system shall send an EventRedirect event to the client causing browser navigation +- When an island has a params handler and receives an EventParams event, the system shall route it to the params handler +- When a form with file inputs is submitted, the system shall upload files via multipart POST, validate, stage, and make them available for consumption +- While a `live-throttle` attribute is present, the system shall fire the event immediately then rate-limit at the specified interval +- While the transport is connected, the system shall add `live-connected` class to document.body +- While the transport is disconnected, the system shall add `live-disconnected` class to document.body +- If the server sends an error event, then the system shall add `live-error` class to document.body + +## Batch Size + +| Metric | Count | Rating | +|--------|-------|--------| +| Tasks | 33 | Large | +| Files | 32 | Large | +| Stages | 6 | Large | + +**Overall: Large** (proceeding as single plan per user preference) + +## Execution Stages + +### Stage 1: Client Event Directives + Test Coverage Foundation + +#### Test Creation Phase (parallel) +- T1: Write tests for Throttler, window events, and live-patch (hmm-test-writer) + - New feature tests (RED): Scenarios 1-6, 16 + - Files: `web/src/events.spec.ts` (modifies) +- T2: Write Go tests for existing coverage gaps (hmm-test-writer) + - Regression tests: transport endpoints, JS serving, BroadcastToIsland, SetStateTTL, children, counter example + - Files: `transport_endpoints_test.go` (creates), `javascript_test.go` (creates), `engine_test.go` (modifies), `instance_test.go` (modifies), `examples/counter/main_test.go` (creates) + +#### Implementation Phase (parallel, depends on Test Creation Phase) +- T3: Implement Throttler, window events, and live-patch in events.ts (hmm-implement-worker, TDD mode) + - Make RED tests pass (GREEN) + - Files: `web/src/events.ts` (modifies) +- T4: Implement Go test coverage improvements (hmm-implement-worker, TDD mode) + - Make RED tests pass (GREEN) + - Files: `transport_endpoints_test.go`, `javascript_test.go`, `engine_test.go`, `instance_test.go`, `examples/counter/main_test.go` + +### Stage 2a: Client Connection Features (depends on Stage 1) + +#### Test Creation Phase +- T5: Write tests for connection CSS classes, redirect, and params handling (hmm-test-writer) + - New feature tests (RED): Scenarios 7-10, 15, 17 + - Files: `web/src/connection.spec.ts` (modifies) + +#### Implementation Phase (depends on Test Creation Phase) +- T6: Implement connection CSS, redirect, and params in connection.ts (hmm-implement-worker, TDD mode) + - Make RED tests pass (GREEN) + - Files: `web/src/connection.ts` (modifies) + +### Stage 2b: Server HandleParams/PatchURL/Redirect (depends on Stage 1, parallel with Stage 2a) + +#### Test Creation Phase +- T7: Write Go tests for HandleParams, PatchURL, Redirect (hmm-test-writer) + - New feature tests (RED): Scenarios 11-14 + - Files: `island_test.go` (modifies), `engine_test.go` (modifies), `instance_test.go` (modifies) + +#### Implementation Phase (depends on Test Creation Phase) +- T8: Implement HandleParams, PatchURL, Redirect in Go (hmm-implement-worker, TDD mode) + - Make RED tests pass (GREEN) + - Files: `island.go` (modifies), `instance.go` (modifies), `session.go` (modifies), `context.go` (modifies) + +### Stage 3: Upload System (depends on Stage 2b) + +#### Test Creation Phase +- T9: Write Go and client tests for upload system (hmm-test-writer) + - New feature tests (RED): Scenarios 18-21 + - Files: `upload_test.go` (creates), `web/src/events.spec.ts` (modifies) + +#### Implementation Phase (depends on Test Creation Phase) +- T10: Implement upload system in Go and client (hmm-implement-worker, TDD mode) + - Make RED tests pass (GREEN) + - Files: `upload.go` (creates), `island.go` (modifies), `engine.go` (modifies), `transport_endpoints.go` (modifies), `web/src/events.ts` (modifies) + +### Stage 4: Buttons + Clocks Examples (depends on Stage 1) + +#### Test Creation Phase (parallel) +- T11: Write tests for buttons example (hmm-test-writer) + - Files: `examples/buttons/main_test.go` (creates) +- T12: Write tests for clocks example (hmm-test-writer) + - Files: `examples/clocks/main_test.go` (creates) + +#### Implementation Phase (parallel, depends on Test Creation Phase) +- T13: Implement buttons example (hmm-implement-worker, TDD mode) + - Files: `examples/buttons/main.go` (creates), `examples/buttons/buttons.html` (creates), `examples/buttons/index.html` (creates) +- T14: Implement clocks example (hmm-implement-worker, TDD mode) + - Files: `examples/clocks/main.go` (creates), `examples/clocks/index.html` (creates) + +### Stage 5: Pagination + Uploads Examples (depends on Stage 2b and Stage 3 respectively) + +#### Test Creation Phase (parallel) +- T15: Write tests for pagination example (hmm-test-writer) + - Files: `examples/pagination/main_test.go` (creates) +- T16: Write tests for uploads example (hmm-test-writer) + - Files: `examples/uploads/main_test.go` (creates) + +#### Implementation Phase (parallel, depends on Test Creation Phase) +- T17: Implement pagination example (hmm-implement-worker, TDD mode) + - Files: `examples/pagination/main.go` (creates), `examples/pagination/pagination.html` (creates), `examples/pagination/index.html` (creates) +- T18: Implement uploads example (hmm-implement-worker, TDD mode) + - Files: `examples/uploads/main.go` (creates), `examples/uploads/uploads.html` (creates), `examples/uploads/index.html` (creates) + +### Stage 6: JS Rebuild (depends on all above) + +#### Implementation Phase +- T19: Rebuild auto.js with all client-side changes (hmm-implement-worker) + - Files: `web/browser/auto.js` (modifies), `web/browser/auto.js.map` (modifies) + +## Task List + +### Client-Side: Event Directives (events.ts) + +All events.ts changes are consolidated into single test + implementation tasks to avoid write-write conflicts. + +- [x] T1: Write tests for throttle, window events, and live-patch [Stage 1] + - Files: `web/src/events.spec.ts` (modifies) + - **Throttle tests**: immediate first fire, rate-limiting subsequent fires, trailing fire after interval, throttle precedence over debounce, throttle with click/key/change events, cleanup of throttle timers. + - **Window event tests**: window-focus/blur fires on window events, window-keydown/keyup with key data, live-key filter on window events, cleanup removes window listeners, loading classes, multiple islands receive same window event, events scoped to declaring island. + - **Live-patch tests**: click prevents default, updates URL via pushState, sends params event with extracted URL params, handles elements without href. + - Tests are written RED before implementation. + +- [x] T3: Implement Throttler, window events, and live-patch in events.ts [Stage 1, depends: T1] + - Files: `web/src/events.ts` (modifies) + - **Throttler class**: Add alongside existing Debouncer. Uses `WeakMap` for per-element `lastFire` timestamps. Methods: `hasThrottle(element)`, `throttle(element, e, fn)`, `cleanup(element)`. Fires immediately on first call, rate-limits subsequent at interval. Stores pending trailing timer. Note: throttle state is per-element-reference via WeakMap, which resets on DOM replacement during patches -- this is acceptable behavior matching debounce. + - **Throttle integration**: Add `private throttler: Throttler` to EventWiring. Modify `wireStandardEvent`, `wireKeyEvent`, `wireChangeEvents` to check throttle before debounce. Throttle takes precedence. + - **Window focus/blur**: Add `wireWindowStandardEvent(eventType, attribute)` private method that queries within island but attaches to `window`. Add `wireWindowFocusEvents()` and `wireWindowBlurEvents()`. + - **Window keydown/keyup**: Add `wireWindowKeyEvent(eventType, attribute)` with `live-key` filter and keyboard metadata. Add `wireWindowKeydownEvents()` and `wireWindowKeyupEvents()`. + - **Live-patch**: Add `wirePatchEvents()` method for `[live-patch]` anchor elements. On click: prevent default, read `href`, call `history.pushState`, extract URL params, send params event. + - All new methods called from `wire()`. All push cleanup functions. + +### Client-Side: Connection Features (connection.ts) + +All connection.ts changes are consolidated into single test + implementation tasks. + +- [x] T5: Write tests for connection CSS, redirect, and params [Stage 2a] + - Files: `web/src/connection.spec.ts` (modifies) + - **Connection CSS tests**: live-connected on connect, live-disconnected on disconnect, class swap on reconnect, live-error on error message, error cleared on reconnect. + - **Redirect tests**: redirect calls window.location.replace, redirect does not call island handlers. + - **Params tests**: incoming params message updates browser URL via pushState. + - Tests are written RED before implementation. + +- [x] T6: Implement connection CSS, redirect, and params in connection.ts [Stage 2a, depends: T5] + - Files: `web/src/connection.ts` (modifies) + - **Connection CSS**: Call existing `EventDispatch.reconnected()` (adds ClassConnected, removes ClassDisconnected) when state is Connected. Call `EventDispatch.disconnected()` when Closed/Reconnecting. Do NOT duplicate CSS class logic -- `EventDispatch` in `event.ts` already manages these classes; ConnectionManager just needs to call the appropriate static methods at the right lifecycle points. + - **Error CSS**: In `routeMessage()`, when `message.t === MessageType.Error`, call `EventDispatch.error()` to add ClassError. Still route to island handler if `message.island` set. + - **Redirect**: In `routeMessage()`, when `message.t === MessageType.Redirect`, call `window.location.replace(message.d)` and return. + - **Params**: In `routeMessage()`, when `message.t === MessageType.Params`, update browser URL via `history.pushState` using `window.location.pathname + "?" + message.d`. + +### Server-Side: HandleParams + PatchURL + Redirect + +All island.go, instance.go, session.go, context.go changes consolidated. + +- [x] T7: Write Go tests for HandleParams, PatchURL, Redirect [Stage 2b] + - Files: `island_test.go` (modifies), `engine_test.go` (modifies), `instance_test.go` (modifies) + - **HandleParams tests**: handler registration, GetParamsHandler, EventParams routing through engine, state update on params event. + - **PatchURL tests**: handler calls PatchURL, transport receives EventParams with correct encoded values. + - **Redirect tests**: handler calls Redirect, transport receives EventRedirect with URL. + - **Edge case tests**: nil params handler is no-op (no error), PatchURL/Redirect with missing context values. + - Tests are written RED before implementation. + +- [x] T8: Implement HandleParams, PatchURL, Redirect in Go [Stage 2b, depends: T7] + - Files: `island.go` (modifies), `instance.go` (modifies), `session.go` (modifies), `context.go` (modifies) + - **island.go**: Add `paramsHandler IslandEventHandler` field to Island struct. Add `HandleParams(handler IslandEventHandler)` method (mutex-guarded). Add `WithHandleParams(handler IslandEventHandler) IslandConfig`. Add `GetParamsHandler() IslandEventHandler` accessor. Add `PatchURL(ctx context.Context, values url.Values)` function -- extracts session via `sessionFromContext`, creates Event{T: EventParams}, sends. Add `Redirect(ctx context.Context, u *url.URL)` function -- creates Event{T: EventRedirect}, sends. + - **instance.go**: Add `CallParams(ctx context.Context, params Params) error` method. Gets params handler, calls with state, updates state. Returns nil if no handler. + - **session.go**: In `routeToIsland()`, add check: if `event.T == EventParams`, call `instance.CallParams()` instead of `instance.CallEvent()`. + - **context.go**: Add `sessionFromContext(ctx context.Context) (*Session, error)` helper that extracts engine and sessionID from context, looks up session via `engine.GetSession(sessionID)`, returns `(*Session, error)`. + +### Server-Side: Upload System + +- [x] T9: Write Go and client tests for upload system [Stage 3] + - Files: `upload_test.go` (creates), `web/src/events.spec.ts` (modifies) + - **Go tests**: ValidateUploads with valid/oversized/too-many/wrong-type files, ConsumeUploads handler called per file, UploadConfig on island, Upload.File() returns staged file, upload endpoint stages files correctly. + - **Client tests**: upload progress event dispatched during XHR upload, file input change triggers validation event. + - Tests are written RED before implementation. + +- [x] T10: Implement upload system in Go and client [Stage 3, depends: T9] + - Files: `upload.go` (creates), `island.go` (modifies), `engine.go` (modifies), `transport_endpoints.go` (modifies), `web/src/events.ts` (modifies) + - **upload.go**: Port and adapt from master: `UploadError` struct, error sentinels (`ErrUploadNotFound`, `ErrUploadTooLarge`, `ErrUploadNotAccepted`, `ErrUploadTooManyFiles`, `ErrUploadMalformed`), `UploadConfig` struct (Name, MaxFiles, MaxSize, Accept), `Upload` struct (Name, Size, Type, LastModified, Errors, Progress, internalLocation), `Upload.File()`, `UploadContext` type `map[string][]*Upload`, `ValidateUploads(params Params, configs []*UploadConfig) (UploadContext, error)`, `ConsumeUploads(uploads UploadContext, name string, handler ConsumeHandler) []error`, `ConsumeHandler func(u *Upload) error`. + - **island.go**: Add `uploadConfigs []*UploadConfig` field. Add `WithUploadConfig(config *UploadConfig) IslandConfig`. Add `UploadConfigs()` accessor. + - **engine.go**: Add `MaxUploadSize int64` and `UploadStagingLocation string` fields with config options. + - **transport_endpoints.go**: Add `UploadHandler(engine *IslandEngine) http.HandlerFunc` for multipart POST. Uses `http.MaxBytesReader` for size enforcement. Validates session ID from form against authenticated transport session (do NOT trust form-supplied session IDs blindly). Stages files, validates against island upload configs. + - **web/src/events.ts**: Upgrade XHR in `wireSubmitEvents()` to use `request.upload.onprogress` for progress tracking. Dispatch `CustomEvent("live:upload-progress")` on form. Add `onerror` handler. In `wireChangeEvents()`, detect `input[type="file"]` and listen for `"change"` event to serialize file metadata. + +### Server-Side: Test Coverage Improvements + +- [x] T2: Write and implement Go coverage tests [Stage 1] + - Files: `transport_endpoints_test.go` (creates), `javascript_test.go` (creates), `engine_test.go` (modifies), `instance_test.go` (modifies), `examples/counter/main_test.go` (creates) + - **transport_endpoints_test.go**: WebSocketHandler returns valid handler that upgrades connections. SSEHandler returns valid handler. SSEHandlerWithFactory returns two handlers (SSE + POST). Use httptest.NewServer. + - **javascript_test.go**: Javascript.ServeHTTP returns Content-Type text/javascript and non-empty body. JavascriptMap.ServeHTTP returns Content-Type application/json and non-empty body. + - **engine_test.go**: BroadcastToIsland cross-session delivery (2 sessions with same island ID both receive event, session with different ID does not). SetStateTTL configuration applied to subsequent MountIsland calls. + - **instance_test.go**: NewIslandInstanceWithChildren stores children, makes them available in render context. + - **examples/counter/main_test.go**: MountWithInitial (props.Int), Increment, Decrement, MountAndRender via engine. Follow clock test pattern. + +### Examples + +- [x] T11: Write tests for buttons example [Stage 4] + - Files: `examples/buttons/main_test.go` (creates) + - Tests: NewButtonsIsland construction, mount handler, inc/dec event handlers. + +- [x] T12: Write tests for clocks example [Stage 4] + - Files: `examples/clocks/main_test.go` (creates) + - Tests: multiple clock islands with different timezones have independent state. + +- [x] T13: Implement buttons example [Stage 4, depends: T11] + - Files: `examples/buttons/main.go` (creates), `examples/buttons/buttons.html` (creates), `examples/buttons/index.html` (creates) + - Counter island with `live-click` for inc/dec and `live-window-keyup` with `live-key="ArrowUp"` / `live-key="ArrowDown"`. + +- [x] T14: Implement clocks example [Stage 4, depends: T12] + - Files: `examples/clocks/main.go` (creates), `examples/clocks/index.html` (creates) + - Reuse clock island type. Page with multiple `` with different `data-timezone` props. + +- [x] T15: Write tests for pagination example [Stage 5] + - Files: `examples/pagination/main_test.go` (creates) + - Tests: mount initializes page 0, HandleParams with page=2 updates items, next-page event handler calls PatchURL. + +- [x] T16: Write tests for uploads example [Stage 5] + - Files: `examples/uploads/main_test.go` (creates) + - Tests: mount returns empty uploads, validate with valid/invalid files, save consumes uploads. + +- [x] T17: Implement pagination example [Stage 5, depends: T15, T8] + - Files: `examples/pagination/main.go` (creates), `examples/pagination/pagination.html` (creates), `examples/pagination/index.html` (creates) + - ListState with Page/Items. HandleParams reads `page` param. Event handler calls PatchURL. Template uses `live-patch` and `live-click`. + +- [x] T18: Implement uploads example [Stage 5, depends: T16, T10] + - Files: `examples/uploads/main.go` (creates), `examples/uploads/uploads.html` (creates), `examples/uploads/index.html` (creates) + - Upload island with WithUploadConfig for "photos" (max 3, 1MB, image/png). Validate event calls ValidateUploads. Save event calls ConsumeUploads. + +- [x] T19: Rebuild auto.js with all client changes [Stage 6] + - Files: `web/browser/auto.js` (modifies), `web/browser/auto.js.map` (modifies) + - Run `cd web && npm run build`. + +## Acceptance Criteria + +~~~gherkin +Feature: Throttle rate-limiting + + Scenario: Throttle fires immediately then rate-limits + Given an element with live-click="test" and live-throttle="500" + When the user clicks the element 5 times rapidly + Then the first click fires immediately + And no further clicks fire until 500ms have elapsed + And a trailing fire occurs after the throttle interval + + Scenario: Throttle takes precedence over debounce + Given an element with live-throttle="500" and live-debounce="200" + When the user clicks the element + Then throttle behavior is applied, not debounce + + Scenario: Throttle cleanup on island unmount + Given an element with live-throttle="500" in an island + When the island is unmounted + Then no trailing throttle fires occur + +Feature: Window-level events + + Scenario: live-window-keyup fires on window keypress + Given an element with live-window-keyup="shortcut" inside an island + When a keyup event fires on the window + Then the island receives the "shortcut" event with key data + + Scenario: live-key filters window key events + Given an element with live-window-keyup="up" live-key="ArrowUp" inside an island + When ArrowUp is pressed on the window + Then the "up" event fires + When "a" is pressed on the window + Then no event fires + + Scenario: Multiple islands receive window events independently + Given island-A has live-window-keyup="action-a" + And island-B has live-window-keyup="action-b" + When a keyup event fires on the window + Then island-A receives "action-a" + And island-B receives "action-b" + +Feature: Connection CSS classes + + Scenario: Connected class applied on connection + Given the page has loaded + When the transport connects successfully + Then document.body has class "live-connected" + And document.body does not have class "live-disconnected" + + Scenario: Disconnected class applied on disconnect + Given the transport is connected + When the transport disconnects + Then document.body has class "live-disconnected" + And document.body does not have class "live-connected" + + Scenario: Error class applied on server error + Given the transport is connected + When a server error event is received + Then document.body has class "live-error" + +Feature: Redirect handling + + Scenario: Server redirect navigates browser + Given an island is connected + When the server sends an EventRedirect with URL "/success" + Then window.location.replace is called with "/success" + +Feature: HandleParams for islands + + Scenario: Params event routes to params handler + Given an island with HandleParams registered + When an EventParams event arrives with page=2 + Then the params handler is called with params containing page=2 + And the island state is updated and re-rendered + + Scenario: No params handler is a no-op + Given an island without HandleParams + When an EventParams event arrives + Then no error occurs and the event is silently ignored + + Scenario: PatchURL sends params to client + Given an island event handler calls PatchURL with page=3 + When the handler completes + Then the transport receives an EventParams event with "page=3" + + Scenario: Redirect sends redirect to client + Given an island event handler calls Redirect with "/done" + When the handler completes + Then the transport receives an EventRedirect event with "/done" + + Scenario: Client receives server-originated params and updates URL + Given the client is connected + When the server sends an EventParams event with "page=3" + Then the browser URL is updated to include "?page=3" via history.pushState + +Feature: Live-patch client navigation + + Scenario: Clicking live-patch updates URL and sends params + Given an anchor with live-patch and href="?page=2" inside an island + When the user clicks the anchor + Then the browser URL is updated to include page=2 + And a params event is sent to the server with page=2 + + Scenario: live-window-focus fires on window focus + Given an element with live-window-focus="focused" inside an island + When a focus event fires on the window + Then the island receives the "focused" event + +Feature: File uploads + + Scenario: Valid file upload is accepted + Given an island with UploadConfig allowing max 1MB PNG files + When a 500KB PNG file is uploaded + Then ValidateUploads returns no errors + And ConsumeUploads provides access to the staged file + + Scenario: Oversized file is rejected + Given an island with UploadConfig allowing max 1MB files + When a 2MB file is uploaded + Then ValidateUploads returns ErrUploadTooLarge + + Scenario: Wrong file type is rejected + Given an island with UploadConfig allowing only image/png + When a text/plain file is uploaded + Then ValidateUploads returns ErrUploadNotAccepted + + Scenario: Too many files are rejected + Given an island with UploadConfig allowing max 3 files + When 5 files are uploaded + Then ValidateUploads returns ErrUploadTooManyFiles + +Feature: Test coverage improvements + + Scenario: Transport endpoint handlers serve correctly + Given a WebSocketHandler is created + When an HTTP request is sent + Then the handler attempts a WebSocket upgrade + + Scenario: JavaScript handler serves correct content + Given a Javascript handler + When an HTTP GET request is sent + Then the response has Content-Type text/javascript + And the response body is non-empty + + Scenario: BroadcastToIsland reaches specific island + Given two sessions with island "chat-1" and one with island "chat-2" + When BroadcastToIsland is called for "chat-1" + Then both sessions with "chat-1" receive the event + And the session with "chat-2" does not + + Scenario: Counter example constructs and handles events + Given a counter island mounted with initial=5 + When the "inc" event is handled + Then the count is 6 +~~~ + +**Source**: Generated from plan context + +## Implementation Notes + +- **Window listener cleanup is critical**: Unlike element-scoped listeners, window listeners persist globally. Each `wireWindow*` call must push a removal function to `cleanupFunctions` that calls `window.removeEventListener` with the exact same handler reference. +- **Re-wiring after patches**: `wire()` is called after every patch (island.ts). Window event listeners must be cleaned up before re-wiring to avoid duplicates. +- **Throttle state across patches**: The Throttler uses `WeakMap` which resets when DOM elements are replaced during patching. This is consistent with Debouncer behavior and acceptable -- throttle state loss on re-wire is expected. +- **EventDispatch integration**: `EventDispatch` in `event.ts` already has `disconnected()`, `reconnected()`, and `error()` static methods that manage CSS classes on `document.body`. ConnectionManager should call these existing methods rather than duplicating class manipulation logic. +- **PatchURL/Redirect data encoding**: `Event.Data` is `json.RawMessage`. Use `json.Marshal(values.Encode())` for PatchURL and `json.Marshal(u.String())` for Redirect to produce properly quoted JSON strings. +- **Upload state in islands**: Upload metadata (UploadContext) is stored as part of the island state struct, not on a Socket. Handlers manage uploads through normal state mutation. +- **Upload endpoint security**: The upload HTTP endpoint must validate the session ID from form fields against the authenticated session (e.g., by verifying against the `live_session` cookie on the request). Do NOT blindly trust user-supplied session IDs in multipart form fields, as this would allow an attacker to route uploads to another session. +- **Upload size enforcement**: The upload endpoint must use `http.MaxBytesReader` to enforce `MaxUploadSize` at the HTTP layer before processing the multipart form. +- **JSDOM test limitations**: Window-level events, `history.pushState`, and `window.location.replace` need mocking strategies in jest/jsdom environment. +- **JS rebuild required**: After all client-side changes, `web/browser/auto.js` must be rebuilt via `cd web && npm run build`. + +## Refs + +- `docs/research/2026-03-01-v2-feature-gaps-and-test-coverage.md` - Gap analysis +- `docs/research/2026-02-25-v2-examples-porting.md` - Example porting strategy +- `docs/research/2026-02-22-v2-branch-state-and-testing-gaps.md` - Previous V2 state analysis diff --git a/docs/plans/v2-islands-architecture.md b/docs/plans/v2-islands-architecture.md new file mode 100644 index 0000000..2a8976f --- /dev/null +++ b/docs/plans/v2-islands-architecture.md @@ -0,0 +1,599 @@ +# Implementation Plan: Live v2 Islands-Only Architecture + +A complete rewrite of the Live framework from full-page live views to a pure islands architecture, where islands are the only abstraction, fully isolated with their own state, and share a single transport connection. + +## Context + +**Research Document**: `docs/research/2026-01-25-islands-component-architecture.md` + +**Key Files (v1 - being replaced)**: +- `handler.go` - Full-page Handler struct (removed in v2) +- `engine.go` - Engine with 1:1 Handler coupling (replaced by IslandEngine) +- `socket.go` - WebSocket-coupled Socket (replaced by Session) +- `page/component.go` - Component abstraction (removed - islands replace this) +- `render.go` - Full-page RenderSocket (replaced by RenderIsland) +- `diff.go` - Position-based anchors (replaced by island-scoped anchors) +- `web/src/socket.ts` - Single WebSocket per page (replaced by transport abstraction) +- `web/src/events.ts` - Document-wide event wiring (replaced by island-scoped) + +**Architectural Notes**: +- v2 is a **breaking change** with no backward compatibility +- Islands are the only abstraction (no full-page views) +- Transport abstraction: WebSocket (primary), SSE, polling fallback +- Single transport connection multiplexes all islands +- Each island has isolated state and lifecycle +- Custom element client API: `` +- **No v2/ directories**: Replace old code directly (we're on v2 branch) +- **No deprecations**: Remove v1 Handler/Engine/Component code as we build replacements + +## Execution Strategy + +Each deliverable below represents a complete, tested feature that should be implemented and committed as a single unit of work. Tests are written alongside implementation. + +### Deliverable 1: Island Core Types [x] COMPLETED +**Files**: `island.go`, `island_test.go`, `errors.go` + +Implementation: +- Define `Props` type with helper methods (`String()`, `Int()`, `Bool()`) +- Define handler function types (`IslandMountHandler`, `IslandRenderHandler`, etc.) +- Define `Island` struct with event/self handler maps +- Implement `NewIsland()` constructor with functional options +- Implement `HandleEvent()`, `HandleSelf()` methods +- Add island-specific errors to `errors.go` + +Tests (`island_test.go`): +- Test `Props` helper methods with various input types +- Test `NewIsland()` with and without config options +- Test `HandleEvent()` registration and retrieval +- Test `HandleSelf()` registration and retrieval +- Test error cases (duplicate events, missing handlers) + +**Commit**: `feat(v2): add Island core types with event registration` + +--- + +### Deliverable 2: Island Registry [x] COMPLETED +**Files**: `registry.go`, `registry_test.go` + +Implementation: +- Define `IslandConstructor` type +- Implement `IslandRegistry` with thread-safe map +- Implement `RegisterIsland()`, `GetIsland()`, `ListIslands()` +- Global singleton registry instance + +Tests (`registry_test.go`): +- Test `RegisterIsland()` success case +- Test duplicate registration error +- Test `GetIsland()` found and not found +- Test `ListIslands()` returns all registered islands +- Test concurrent registration safety (goroutine race test) + +**Commit**: `feat(v2): add IslandRegistry with thread-safe island registration` + +--- + +### Deliverable 3: Island Instance [x] COMPLETED +**Files**: `instance.go`, `instance_test.go` + +Implementation: +- Define `IslandInstance` struct (ID, Type, island ref, state, props, children) +- Implement `NewIslandInstance()` constructor +- Implement lifecycle: `Mount()`, `Render()`, `Unmount()` +- Implement event handling: `CallEvent()`, `CallSelf()` +- Implement state access: `State()`, `SetState()` + +Tests (`instance_test.go`): +- Test `NewIslandInstance()` with props +- Test `Mount()` calls island's mount handler and stores state +- Test `Render()` calls island's render handler with current state +- Test `CallEvent()` updates state and calls handler +- Test `CallSelf()` handles self-targeted events +- Test `Unmount()` cleanup +- Test state isolation between instances + +**Commit**: `feat(v2): add IslandInstance with lifecycle management` + +--- + +### Deliverable 4: Types and Protocol Changes [x] COMPLETED +**Files**: `types.go`, `event.go`, `patch.go` + +Implementation: +- Create `IslandID` and `SessionID` type aliases +- Add `Island string` field to `Event` struct +- Create `IslandPatch` wrapper type with island ID +- Add `IslandID` field to `Patch` struct + +Tests (modify existing `event_test.go`, `diff_test.go`): +- Test Event JSON serialization with island field +- Test IslandPatch wrapping and unwrapping +- Test Patch with island ID field + +**Commit**: `feat(v2): add island-scoped event and patch protocol` + +--- + +### Deliverable 5: Transport Interface (Server) [x] COMPLETED +**Files**: `transport.go`, `transport_test.go` + +Implementation: +- Define `Transport` interface (`Send()`, `Events()`, `Close()`) +- Define `TransportFactory` interface for HTTP upgrades +- Define transport configuration types + +Tests (`transport_test.go`): +- Create mock transport implementing interface +- Test Send/Receive contract +- Test Close cleanup +- Test context cancellation + +**Commit**: `feat(v2): add Transport interface abstraction` + +--- + +### Deliverable 6: WebSocket Transport (Server) [x] COMPLETED +**Files**: `transport_websocket.go`, `transport_websocket_test.go`, `transport_endpoints.go` + +Implementation: +- Extract WebSocket logic from v1 `engine.go:491-652` +- Implement `WebSocketTransport` struct +- Implement `Send()`, `Events()`, `Close()` +- Handle Safari compression workaround +- Create `WebSocketHandler()` endpoint + +Tests (`transport_websocket_test.go`): +- Test WebSocket upgrade handshake +- Test bidirectional message flow +- Test reconnection behavior +- Test Safari compression handling +- Integration test with real WebSocket connection + +**Commit**: `feat(v2): add WebSocket transport implementation` + +--- + +### Deliverable 7: Island State Store [x] COMPLETED +**Files**: `statestore.go`, `statestore_test.go` + +Implementation: +- Define `IslandStateStore` interface with composite keys +- Implement `MemoryIslandStateStore` with janitor +- Implement `Get(SessionID, IslandID)`, `Set()`, `Delete()` +- TTL-based cleanup goroutine + +Tests (`statestore_test.go`): +- Test Get/Set/Delete with composite keys +- Test TTL expiration via janitor +- Test concurrent access safety +- Test cleanup on session end + +**Commit**: `feat(v2): add IslandStateStore with composite key storage` + +--- + +### Deliverable 8: Session [x] COMPLETED +**Files**: `session.go`, `session_test.go` + +Implementation: +- Define `Session` struct (ID, transport, islands map, channels) +- Implement session lifecycle methods +- Implement island instance tracking per session +- Message routing to islands within session + +Tests (`session_test.go`): +- Test session creation with transport +- Test adding/removing islands from session +- Test message routing to correct island +- Test session cleanup + +**Commit**: `feat(v2): add Session for transport-agnostic connection management` + +--- + +### Deliverable 9: Island-Scoped Anchoring [x] COMPLETED +**Files**: `diff.go`, `diff_test.go` + +Implementation: +- Add `islandAnchorPrefix = "_i"` constant +- Create `islandAnchorGenerator` struct +- Implement island-scoped anchor format: `_i_{islandID}_{path}` +- Update `hasAnchor()` to check both `_l` and `_i` prefixes + +Tests (modify `diff_test.go`): +- Test `islandAnchorGenerator.String()` format +- Test anchor generation for nested elements +- Test anchor uniqueness across different islands +- Test backward compatibility with `_l` prefix + +**Commit**: `feat(v2): add island-scoped anchor generation` + +--- + +### Deliverable 10: Island Rendering and Diffing [x] COMPLETED +**Files**: `render.go`, `diff.go`, `render_test.go` + +Implementation: +- Create `IslandRenderContext` struct +- Implement `RenderIsland(ctx, island, state)` function +- Implement `DiffIsland(islandID, current, proposed)` function +- Update `Patch` generation to include island ID + +Tests (`render_test.go`, extend `diff_test.go`): +- Test `RenderIsland()` with template +- Test `DiffIsland()` generates island-scoped patches +- Test patch island ID is set correctly +- Test multiple islands diff independently + +**Commit**: `feat(v2): add island-scoped rendering and diffing` + +--- + +### Deliverable 11: Island Engine [x] COMPLETED +**Files**: `engine.go` (create new v2 version), `engine_test.go` + +Implementation: +- Create `IslandEngine` struct (registry, sessions, stateStore) +- Implement channel-based session management +- Implement `AddSession()`, `GetSession()`, `DeleteSession()` +- Implement `MountIsland()`, `UnmountIsland()` +- Implement `RouteEvent()` message routing +- Implement `BroadcastToIslandType()`, `BroadcastToIsland()` + +Tests (`engine_test.go`): +- Test session lifecycle (add/get/delete) +- Test island mounting with props +- Test event routing to correct island +- Test state updates after events +- Test unmounting cleanup +- Test broadcast targeting +- Test concurrent operations safety + +**Commit**: `feat(v2): add IslandEngine with session and island lifecycle management` + +--- + +### Deliverable 12: Client Transport Layer [x] COMPLETED +**Files**: `web/src/transport/transport.ts`, `web/src/transport/message.ts`, `web/src/transport/websocket.ts`, `web/src/transport/websocket.spec.ts` + +**Delete**: `web/src/socket.ts` (replaced by transport abstraction) + +Implementation: +- Define TypeScript `Transport` interface +- Define `TransportMessage` and `IslandPatch` types +- Implement `WebSocketTransport` class (extract logic from old `socket.ts`) +- Auto-reconnection with backoff +- Session ID management (cookie-based) +- Island subscription routing + +Tests (`websocket.spec.ts`): +- Test connection establishment +- Test message send/receive with island field +- Test island subscription +- Test reconnection with backoff +- Test session ID persistence + +**Commit**: `feat(v2): replace socket.ts with transport abstraction and WebSocket transport` + +--- + +### Deliverable 13: SSE and Transport Negotiation [x] COMPLETED +**Files**: `web/src/transport/sse.ts`, `web/src/transport/sse.spec.ts`, `web/src/transport/negotiator.ts`, `web/src/transport/negotiator.spec.ts` + +Implementation: +- Implement `SSETransport` class (EventSource + fetch POST) +- Implement `TransportNegotiator` with fallback chain +- Auto-select transport: WebSocket → SSE → Polling + +Tests: +- `sse.spec.ts`: Test SSE stream, POST events, reconnection +- `negotiator.spec.ts`: Test fallback chain, timeout handling + +**Commit**: `feat(v2): add SSE transport and auto-negotiation` + +--- + +### Deliverable 14: Connection Manager [x] COMPLETED +**Files**: `web/src/connection.ts`, `web/src/connection.spec.ts` + +Implementation: +- Implement `ConnectionManager` singleton +- Manage shared transport connection across all islands +- Island registration/deregistration +- Message routing to island subscribers +- Reconnection with island re-subscription + +Tests (`connection.spec.ts`): +- Test singleton pattern +- Test island registration +- Test message routing to correct island +- Test reconnection re-subscribes islands + +**Commit**: `feat(v2): add ConnectionManager for shared island connections` + +--- + +### Deliverable 15: LiveIsland Custom Element [x] COMPLETED +**Files**: `web/src/island.ts`, `web/src/island.spec.ts` + +**Delete**: `web/src/live.ts` (old single-page initialization) + +Implementation: +- Define `IslandProps`, `IslandConfig` types +- Implement `LiveIsland` custom element class +- `connectedCallback()`: extract props, register with ConnectionManager +- `disconnectedCallback()`: unsubscribe, cleanup +- `attributeChangedCallback()`: handle prop updates +- Props extraction from `type`, `id`, `data-*` attributes + +Tests (`island.spec.ts`): +- Test custom element registration +- Test `connectedCallback()` props extraction +- Test connection registration on connect +- Test disconnection cleanup +- Test attribute changes trigger updates + +**Commit**: `feat(v2): replace Live class with LiveIsland custom element` + +--- + +### Deliverable 16: Island-Scoped Event Wiring [x] COMPLETED +**Files**: `web/src/events.ts` (replace contents), `web/src/events.spec.ts` + +Implementation: +- Replace document-wide event handlers with island-scoped +- `querySelectorAll` within island element, not document +- Support `live-*` attributes within island boundary +- Debounce and limiter support preserved +- Loading state management per island + +Tests (`events.spec.ts`): +- Replace existing tests with island-scoped versions +- Test click, submit, keydown handlers within island +- Test debounce functionality +- Test loading class application +- Test events don't leak between islands + +**Commit**: `feat(v2): replace document-wide events with island-scoped wiring` + +--- + +### Deliverable 17: Island-Scoped Patch Handling [x] COMPLETED +**Files**: `web/src/patch.ts` (replace contents), `web/src/patch.spec.ts` + +Implementation: +- Replace document-wide patching with island-scoped +- Query anchors within island element only +- Form state preservation within island (keep `forms.ts` logic) +- Lifecycle events (beforeUpdate, updated, destroyed) +- Re-wire events after patching + +Tests (`patch.spec.ts`): +- Replace existing tests with island-scoped versions +- Test patch application within island +- Test replace, append, prepend actions +- Test form state preservation +- Test lifecycle event dispatch +- Test event re-wiring after patch + +**Commit**: `feat(v2): replace document-wide patching with island-scoped patches` + +--- + +### Deliverable 18: Island Hooks and Entry Point [x] COMPLETED +**Files**: `web/src/hooks.ts`, `web/src/auto.ts` (replace contents), `web/src/index.ts` (replace contents) + +**Keep**: `web/src/interop.ts` (Hook interface still valid), `web/src/forms.ts` (form utils still needed) +**Delete**: Old files no longer needed + +Implementation: +- Update hooks to be island-scoped +- `pushEvent` bound to island transport/ID +- Replace `auto.ts` initialization with custom element registration +- Update `index.ts` to export v2 API + +Tests (`hooks.spec.ts`): +- Test hook registration per island +- Test `pushEvent` sends to correct island +- Test lifecycle hooks called at right times + +**Commit**: `feat(v2): update hooks and entry point for islands architecture` + +--- + +### Deliverable 19: Client Build Configuration [x] COMPLETED +**Files**: `web/package.json` + +Implementation: +- Update build script to bundle from new entry point +- Ensure all new transport/island files are included +- Update test patterns for new file structure + +**Commit**: `chore(v2): update client build for islands architecture` + +--- + +### Deliverable 22: SSE Transport (Server) [x] COMPLETED +**Files**: `transport_sse.go`, `transport_sse_test.go` + +Implementation: +- Implement `SSETransport` struct +- Server-Sent Events streaming +- HTTP POST endpoint for client events +- Last-Event-ID support for reconnection + +Tests (`transport_sse_test.go`): +- Test SSE stream setup +- Test event delivery to client +- Test POST event reception +- Test Last-Event-ID reconnection + +**Commit**: `feat(v2): add SSE transport for server-to-client streaming` + +--- + +### Deliverable 23: Remove v1 Code [x] COMPLETED +**Files Deleted**: +- `page/component.go` - v1 component abstraction (replaced by Island) +- `page/configuration.go` - v1 component configs +- `page/render.go` - v1 component rendering +- `handler.go` - v1 full-page Handler (replaced by Island) +- Old `engine.go` - v1 Engine (replaced by IslandEngine) +- `socket.go` - v1 Socket (replaced by Session) +- All v1 examples in `examples/` (replaced by v2 examples) + +Implementation: +- Remove all v1 Handler/Engine/Component code +- Remove v1 full-page examples +- Clean up any v1-specific imports + +**Commit**: `chore(v2): remove v1 Handler, Engine, Component, and Socket code` + +--- + +### Deliverable 24: Counter Example [x] COMPLETED +**Files**: `examples/counter/main.go`, `examples/counter/index.html`, `examples/counter/counter.html` + +Implementation: +- Create counter island with inc/dec events +- Register island with `live.RegisterIsland()` +- HTML with `` +- Demonstrate props passing (`initial-value="5"`) +- Serve all transports (WebSocket, SSE) + +Manual Testing: +- Load example in browser +- Test increment/decrement +- Test WebSocket transport +- Test SSE fallback +- Test reconnection +- Test multiple counter instances on same page + +**Commit**: `feat(v2): add counter example demonstrating islands API` + +--- + +### Deliverable 25: Integration Tests [x] COMPLETED +**Files**: `integration_test.go` + +Implementation: +- End-to-end test with real transport +- Test island mount → event → render → patch flow +- Test multiple islands on same session +- Test transport reconnection with state preservation + +Tests (`integration_test.go`): +- Full lifecycle integration test +- Multi-island coordination test +- Reconnection state preservation test + +**Commit**: `test(v2): add end-to-end integration tests` + +--- + +### Optional Deliverable: Polling Transport ✓ Commit Point +**Files**: `transport_polling.go`, `transport_polling_test.go`, `web/src/transport/polling.ts`, `web/src/transport/polling.spec.ts` + +Implementation: +- Server: Long-polling implementation +- Client: Fetch-based polling +- Event ID tracking for continuity +- Integrate into negotiator fallback chain + +Tests: Server and client polling tests + +**Commit**: `feat(v2): add polling transport fallback` + +## Implementation Order Summary + +The 25 deliverables above should be implemented in order, as each builds on previous work. Key milestones: + +**Deliverables 1-3**: Island foundation (types, registry, instances) +**Deliverables 4-8**: Protocol changes and server infrastructure +**Deliverables 9-11**: Rendering and engine implementation +**Deliverables 12-19**: Client rewrite (transport, custom element, events, patches) +**Deliverable 20-22**: SSE transport and final client integration +**Deliverable 23**: **Remove all v1 code** (Handler, Engine, Component, Socket) +**Deliverables 24-25**: Examples and integration tests + +Each deliverable is a complete, tested feature with a commit point. + +## Implementation Notes + +### Cross-cutting Concerns + +1. **State Isolation**: Each island instance has completely isolated state. Cross-island communication only via explicit events. + +2. **Transport Abstraction**: All server code uses `Transport` interface, never WebSocket directly. Client negotiates best transport. + +3. **Session vs Socket**: v2 `Session` is transport-agnostic. It manages multiple `IslandInstance` objects, each with their own lifecycle. + +4. **Anchor Format**: v2 anchors use `_i_{islandID}_{path}` format. Client patches only within `` container. + +5. **Protocol Changes**: Events include `island` field. Patches wrapped with island ID for routing. + +### Edge Cases + +1. **Nested Islands**: Parent island render may contain child `` elements. Treat as opaque boundaries - don't diff into child content. + +2. **Island Reconnection**: If transport reconnects, islands re-subscribe. State restored from IslandStateStore. + +3. **Props Changes**: If HTML attributes change, `attributeChangedCallback` fires. Consider whether to re-mount or update. + +4. **Multiple Same-Type Islands**: `` and `` are fully independent instances. + +### Testing Requirements + +- All Go tests pass with race detection (`go test -race ./...`) +- All TypeScript/Jest tests pass +- Counter example works end-to-end with WebSocket +- Counter example works with SSE fallback + +## Code Removal Strategy + +### v1 Code to Remove (Deliverable 23) + +**Server-side (Go)**: +- `handler.go` - Full-page Handler struct → Replaced by `island.go` +- `engine.go` - v1 Engine with Handler coupling → Replaced by new IslandEngine +- `socket.go` - WebSocket-coupled Socket → Replaced by `session.go` +- `page/component.go` - v1 component abstraction → Replaced by Island/IslandInstance +- `page/configuration.go` - Component configs → Integrated into Island +- `page/render.go` - Component rendering helpers → Integrated into Island + +**Client-side (TypeScript)**: +- `web/src/socket.ts` - Single WebSocket → Replaced by `web/src/transport/websocket.ts` +- `web/src/live.ts` - Old initialization → Replaced by `web/src/island.ts` custom element +- `web/src/events.ts` - Document-wide → Replaced with island-scoped version +- `web/src/patch.ts` - Position-based → Replaced with island-scoped version + +**Examples**: +- All `examples/` subdirectories → Replaced by new v2 examples + +### Confirmation: No v1 Component Code Remains + +After Deliverable 23, the following v1 patterns are **completely removed**: +- ❌ `Handler` with page-level MountHandler/RenderHandler +- ❌ `Engine` with 1:1 Handler coupling +- ❌ `Socket` with WebSocket dependency +- ❌ `page.Component` abstraction +- ❌ Full-page render cycle +- ❌ Position-based anchors (`_l_0_1_0`) +- ❌ Document-wide event wiring +- ❌ Single-page WebSocket connection + +Replaced by v2 patterns: +- ✅ `Island` and `IslandInstance` for component logic +- ✅ `IslandEngine` with session management +- ✅ `Session` transport-agnostic connections +- ✅ `Transport` interface with WebSocket/SSE implementations +- ✅ Island-scoped anchors (`_i_islandID_0_1`) +- ✅ Island-scoped event wiring +- ✅ `` custom element +- ✅ Shared connection with island routing + +## Refs + +- Research document: `docs/research/2026-01-25-islands-component-architecture.md` +- Phoenix LiveView components pattern +- Astro Islands architecture +- Web Components / Custom Elements spec diff --git a/docs/research/2026-01-25-islands-component-architecture.md b/docs/research/2026-01-25-islands-component-architecture.md new file mode 100644 index 0000000..2531aff --- /dev/null +++ b/docs/research/2026-01-25-islands-component-architecture.md @@ -0,0 +1,1070 @@ +--- +date: 2026-01-25T10:30:00+00:00 +researcher: Claude +topic: "Live v2: Islands-Only Architecture (Breaking Redesign)" +tags: [research, architecture, v2, islands, breaking-change] +last_updated: 2026-01-25 +last_updated_by: Claude +last_updated_note: "Updated to reflect v2 as clean break with no backward compatibility" +version: "v2.0.0" +breaking: true +--- + +# Research: Live v2 - Islands-Only Architecture + +**Version**: v2.0.0 (Breaking Redesign) + +## Research Question + +How to redesign Live v2 as a pure islands architecture, where: +- Islands are the only abstraction (no full-page views) +- Islands can be included multiple times and work independently +- Islands can be nested within other islands +- Islands accept props like Angular/React components +- Each island is fully isolated with its own state +- Multiple islands share a single WebSocket connection + +## v2 Goals + +1. **Islands-only API**: Remove all full-page view concepts +2. **True isolation**: Each island has its own state, events, and lifecycle +3. **Component-like DX**: Props, nesting, composition patterns familiar to frontend developers +4. **Efficient networking**: Shared WebSocket with message routing +5. **Web standards**: Built on custom elements for natural browser integration +6. **Minimal boilerplate**: Simple registration and usage patterns +7. **Static-friendly**: Islands embedded in any HTML (static sites, SSR, etc.) + +## Summary + +**This research documents the v2 architecture - a complete rewrite with no backward compatibility.** + +The Live v1 library operates as a **full-page live view system** where a single WebSocket connection manages the entire page state. The architecture follows Phoenix LiveView's model: one Handler per route, one Socket per connection, and a single render tree for the entire page. + +Live v2 will replace this with an **islands-only architecture**: + +**v1 (Current):** +- Single Handler per page route +- Single WebSocket per page +- Full-page render cycle +- Components share resources + +**v2 (Target):** +- No concept of "pages" - only islands +- Shared WebSocket with island routing +- Islands render independently +- Fully isolated island state +- Custom element-based embedding (``) +- True component nesting +- Props-based configuration + +This is a clean architectural break that eliminates full-page patterns entirely. + +## Detailed Findings + +### v1 Architecture Analysis (For Comparison) + +The following documents v1's full-page architecture to understand what v2 is replacing: + +#### Handler Structure (`handler.go:43-62`) + +The Handler struct is designed as a single controller for an entire page: + +```go +type Handler struct { + MountHandler MountHandler // Called on GET and WS connect + UnmountHandler UnmountHandler // Called on WS disconnect + RenderHandler RenderHandler // Renders entire page + ErrorHandler ErrorHandler // Handles errors + eventHandlers map[string]EventHandler // ALL event handlers for page + selfHandlers map[string]SelfHandler // ALL self handlers for page + paramsHandlers []EventHandler // URL param handlers +} +``` + +All event handlers are registered in a single flat map. When a component registers an event, it uses a scoped name (`componentID--eventName`) but the handler still belongs to the single Handler instance. + +#### Engine and Socket Relationship (`engine.go:54-86`) + +The Engine manages all connected sockets for a single Handler: + +```go +type Engine struct { + Handler *Handler // Single handler for all sockets + addSocketC chan engineAddSocket + getSocketC chan engineGetSocket + deleteSocketC chan engineDeleteSocket + iterateSocketsC chan engineIterateSockets + socketStateStore SocketStateStore // Shared state store +} +``` + +Key architectural constraints: +- One Engine per Handler +- Engine tracks all connected sockets in a single map (`engine.go:109-135`) +- Broadcasts go to ALL sockets on the engine (`engine.go:166-173`) +- State store is shared across all sockets + +#### WebSocket Connection (`socket.ts:46-56`) + +The client creates a single WebSocket per page: + +```typescript +this.conn = new WebSocket( + `${location.protocol === "https:" ? "wss" : "ws"}://${ + location.host + }${location.pathname}${location.search}${location.hash}` +); +``` + +The WebSocket URL is derived from the current page URL, meaning one connection per page load. + +#### Rendering Pipeline (`render.go:21-51`, `engine.go:200-209`) + +Every state change triggers a full render cycle: + +```go +func RenderSocket(ctx context.Context, e *Engine, s *Socket) (*html.Node, error) { + rc := &RenderContext{ + Socket: s, + Uploads: s.Uploads(), + Assigns: s.Assigns(), // Gets ALL socket state + } + output, err := e.Handler.RenderHandler(ctx, rc) + // ... parse, diff, patch +} +``` + +The RenderHandler receives the entire socket state and renders the complete page. Diffing then calculates patches, but the full template is always executed. + +### Existing Component System (`page/component.go`) + +The `page` package provides a component abstraction that partially addresses the requirements: + +#### Component Structure (`page/component.go:39-66`) + +```go +type Component struct { + ID string // Unique stable ID + Handler *live.Handler // Reference to HOST handler (shared) + Socket *live.Socket // Reference to socket (shared) + Register RegisterHandler // Setup event handlers + Mount MountHandler // Initialize state + Render RenderHandler // Render this component + State any // Component-specific state + Uploads live.UploadContext +} +``` + +**Current Capabilities:** +- Components have their own ID and State +- Components can render themselves independently +- Event scoping via `c.Event(name)` returns `"componentID--name"` (`page/component.go:146-148`) + +**Current Limitations:** +- Components share the same Handler (event handlers are registered globally) +- Components share the same Socket (single WebSocket connection) +- All components re-render on any state change (no isolation) + +#### Event Scoping (`page/component.go:121-130`) + +```go +func (c *Component) HandleEvent(event string, handler EventHandler) { + c.Handler.HandleEvent(c.Event(event), func(ctx context.Context, s *live.Socket, p live.Params) (any, error) { + state, err := handler(ctx, p) + c.State = state + return s.Assigns(), nil // Returns FULL socket assigns + }) +} +``` + +While events are scoped by name, the handler: +1. Registers on the shared Handler +2. Returns the full socket assigns, triggering full-page re-render + +#### Component Initialization and Nesting (`page/component.go:88-100`) + +```go +func Init(ctx context.Context, construct func() (*Component, error)) (*Component, error) { + comp, err := construct() + if err := comp.Register(comp); err != nil { ... } + if err := comp.Mount(ctx, comp); err != nil { ... } + return comp, nil +} +``` + +Nesting is demonstrated in `examples/components/page.go:70-85`: + +```go +clock, err := page.Init(context.Background(), func() (*page.Component, error) { + return NewClock( + fmt.Sprintf("clock-%d", len(state.Clocks)+1), + c.Handler, // Parent's handler passed to child + c.Socket, // Parent's socket passed to child + tz, + ) +}) +state.Clocks = append(state.Clocks, clock) +``` + +Children receive the parent's Handler and Socket, creating tight coupling. + +### Client-Side Architecture + +#### Single Socket Connection (`socket.ts`) + +The client maintains one global WebSocket: +- Socket ID stored in cookie (`_psid`) +- Connection derived from page URL +- All events sent through single connection +- All patches received on single connection + +#### Event Wiring (`events.ts`) + +Events are wired by scanning DOM for `live-*` attributes: + +```typescript +document.querySelectorAll(`*[${this.attribute}]`).forEach((element) => { + element.addEventListener(this.event, (e) => { + Socket.sendAndTrack(new LiveEvent(t, params, LiveEvent.GetID()), element); + }); +}); +``` + +Events are sent with the scoped event name but through the single WebSocket. + +#### Patch Application (`patch.ts`) + +Patches target elements by anchor attribute: + +```typescript +static applyPatch(e: PatchEvent) { + const target = document.querySelector(`*[${e.Anchor}]`); + target.outerHTML = e.HTML; +} +``` + +Anchors are hierarchical (`_l_0_1_0`) and generated during full-tree diffing. + +### Examples Analysis + +#### Simple Handler Pattern (`examples/buttons/main.go`) + +Full-page handler with global event handlers: + +```go +h := live.NewHandler(live.WithTemplateRenderer(t)) +h.MountHandler = func(ctx context.Context, s *live.Socket) (any, error) { + return newCounter(s), nil +} +h.HandleEvent(inc, func(ctx context.Context, s *live.Socket, _ live.Params) (any, error) { + c := newCounter(s) + c.Value += 1 + return c, nil +}) +``` + +#### Component Pattern (`examples/clocks/main.go`) + +Uses page components but still creates single handler: + +```go +h := live.NewHandler( + page.WithComponentMount(func(ctx context.Context, h *live.Handler, s *live.Socket) (*page.Component, error) { + return components.NewPage("app", h, s, "Clocks") + }), + page.WithComponentRenderer(), +) +http.Handle("/", live.NewHttpHandler(context.Background(), h)) +``` + +The page component owns child clock components, but all share the single Handler/Socket. + +#### Nested Components (`examples/components/`) + +Parent page creates clock children dynamically: + +```go +// Parent stores child references +type PageState struct { + Clocks []*page.Component +} + +// Child created with parent's Handler/Socket +clock, _ := page.Init(ctx, func() (*page.Component, error) { + return NewClock("clock-1", c.Handler, c.Socket, "Europe/London") +}) +``` + +## Architecture Documentation + +### Current Data Flow + +``` +[Browser] + | + | HTTP GET / + v +[Engine.get()] --> [Handler.MountHandler] --> [RenderSocket] --> HTML Response + | + | WebSocket Upgrade + v +[Engine._serveWS()] --> [AddSocket] --> [MountHandler again] --> [RenderSocket] + | + | Client Event (e.g., "clock-1--tick") + v +[Engine.CallEvent()] --> [Handler.eventHandlers["clock-1--tick"]] + | | + | v + | [handler updates state] + | | + v v +[RenderSocket()] <-------- [sock.Assign(data)] + | + | Full page re-render + v +[Diff()] --> [Patches] --> [sock.Send(EventPatch, patches)] + | + v +[Browser: Patch.applyPatch()] +``` + +### Key Coupling Points + +1. **Engine ↔ Handler**: 1:1 relationship, Engine processes all events through single Handler +2. **Handler ↔ Events**: Single flat map holds all event handlers for entire page +3. **Socket ↔ State**: Socket's Assigns() returns all state, not component-scoped state +4. **Render ↔ State**: RenderContext receives full socket state, renders full page +5. **Client ↔ Server**: Single WebSocket, single socket ID cookie + +### DOM Anchoring System (`diff.go`) + +The diffing system uses hierarchical anchors: + +```go +func (n anchorGenerator) String() string { + out := "_l" // liveAnchorPrefix + for _, i := range n.idx { + out += fmt.Sprintf("_%d", i) + } + return out // e.g., "_l_0_1_0" +} +``` + +Anchors are generated by tree position, not by component ID. This means: +- Moving a component in the DOM changes its anchors +- Component boundaries are not preserved in anchoring +- Patches are position-based, not component-based + +## External Context + +### Phoenix LiveView Components + +Phoenix LiveView's component architecture provides relevant patterns: + +1. **LiveComponent** - Components run in parent process but maintain separate socket state +2. **Stateful vs Stateless** - Components can be stateless (pure functions) or stateful (maintain state) +3. **Communication Patterns**: + - Parent-to-child: Explicit assign passing + - Child-to-parent: `send(self(), {:event, data})` + - Cross-component: `send_update/3` for targeted updates + +4. **Two Competing Patterns**: + - "LiveView as Source of Truth": Parent owns state, components request updates + - "Component as Source of Truth": Components manage own state, parent passes identifiers + +### Astro Islands Architecture + +Astro's islands pattern represents the target architecture: + +1. **Static by default** - Most content is static HTML +2. **Selective hydration** - Only interactive regions get JavaScript +3. **Complete isolation** - Islands don't share state unless explicitly connected +4. **Independent loading** - Each island hydrates separately +5. **Framework agnostic** - Different frameworks per island + +**Key patterns from Astro:** +- Hydration directives: `client:load`, `client:idle`, `client:visible` +- No global state by default +- State sharing via stores or custom events +- Parallel loading with no blocking + +### Go LiveView Implementations + +Other Go LiveView implementations show similar patterns: + +- **go-live-view**: Uses stateful structs per connection with dynamic components for optimization +- **jfyne/live**: Current implementation with component abstraction + +## Code References + +### Core Architecture +- `handler.go:43-62` - Handler struct with single event handler maps +- `engine.go:54-86` - Engine struct coupling Handler and socket management +- `engine.go:109-135` - Socket map management in single goroutine +- `engine.go:256-274` - CallEvent routing to single Handler's event map +- `socket.go:31-44` - Socket struct with single engine reference +- `socket.go:121-136` - Assigns/Assign operating on shared state store + +### Component System +- `page/component.go:39-66` - Component struct with shared Handler/Socket references +- `page/component.go:88-100` - Init function for component lifecycle +- `page/component.go:121-130` - HandleEvent registering on shared Handler +- `page/component.go:146-148` - Event name scoping via ID prefix + +### Rendering Pipeline +- `render.go:21-51` - RenderSocket full-page rendering +- `diff.go:33-68` - anchorGenerator hierarchical anchoring +- `diff.go:143-155` - anchorTree applying anchors by position + +### Client Code +- `web/src/socket.ts:46-56` - Single WebSocket connection per page +- `web/src/live.ts:9-25` - Single initialization per page +- `web/src/events.ts` - DOM-wide event scanning and wiring +- `web/src/patch.ts:23-57` - Position-based patch application + +### Examples +- `examples/clocks/main.go:13-20` - Full-page handler with component mount +- `examples/components/page.go:70-85` - Child component creation with shared resources +- `examples/components/clock.go:102-108` - Component configuration pattern + +## Historical Context (from docs/) + +- `docs/knowledge/project.md` - Describes Live as "alternative to React, Vue, Angular" with server-rendered HTML and WebSocket updates +- `docs/knowledge/tech-stack.md` - Minimal dependencies, stdlib-focused design +- `docs/knowledge/guidelines.md` - Preference for small interfaces, explicit error handling + +## Related Research + +No existing research documents found in `docs/research/`. + +## Design Decisions (Resolved) + +Based on discussion with project owner, the following architectural decisions have been made: + +### 1. Connection Model: Shared Connection with Routing +- **Decision**: Single connection per page (transport-agnostic), with message routing to individual islands +- **Rationale**: More efficient than separate connections per island, reduces server load +- **Implications**: + - Need to add island ID to event protocol (`{t: "event", i: 123, island: "counter-1", d: {...}}`) + - Server needs routing layer to dispatch events to correct island handler + - Client needs to scope patches by island container + - **Transport abstraction**: Support WebSocket, SSE, and potentially long-polling + +### 2. State Isolation: Fully Isolated +- **Decision**: Each island has its own state store, cannot access other islands' state +- **Rationale**: True component isolation, prevents accidental coupling between islands +- **Implications**: + - Replace `Socket.Assigns()` with island-scoped state access + - Each island gets its own state key in the store (e.g., `socketID:islandID`) + - No shared state between islands by default + - Cross-island communication via explicit events only + +### 3. Nesting Model: True Nesting (React/Angular Style) +- **Decision**: Islands can contain other islands, forming hierarchies +- **Rationale**: Matches developer expectations from React/Angular/Vue +- **Implications**: + - Need parent-child relationship tracking + - Event bubbling or explicit parent communication + - Nested islands share the same WebSocket but have isolated state + - Child islands scoped within parent's DOM container + +### 4. Embedding: Custom Element +- **Decision**: Use `` custom elements +- **Rationale**: Clean, declarative, works with any templating system +- **Implications**: + - Define `LiveIsland` custom element class in client + - Custom element handles lifecycle: connectedCallback, disconnectedCallback + - Attributes become island props: `` + - Shadow DOM optional (can use light DOM for easier styling) + +### 5. Server Registration: Central Registry +- **Decision**: Register all islands at startup via `live.RegisterIsland("counter", counterHandler)` +- **Rationale**: Explicit, predictable, easy to understand and debug +- **Implications**: + - Global island registry in the `live` package + - Single endpoint handles all islands (e.g., `/_live/ws`) + - Island ID in initial connection identifies which handler to use + - Hot reload could re-register islands without server restart + +### 6. Transport Methods: Pluggable Transports +- **Decision**: Support multiple transport methods (WebSocket, SSE, long-polling) +- **Rationale**: Different environments have different requirements (proxies, firewalls, read-heavy vs interactive) +- **Transports**: + - **WebSocket**: Default for full-duplex, low-latency scenarios + - **SSE (Server-Sent Events)**: Read-heavy updates, better proxy compatibility, HTTP POST for client events + - **Long-polling**: Fallback for restrictive environments +- **Implications**: + - Transport abstraction interface: `Transport.Send()`, `Transport.Receive()` + - Client auto-negotiates transport (try WebSocket, fallback to SSE, fallback to polling) + - Server implements same island protocol over any transport + - Different endpoints per transport: `/_live/ws`, `/_live/sse`, `/_live/poll` + +## v2 Breaking Changes + +The following v1 concepts are **completely removed** in v2: + +### Removed Types +- `Handler` struct - No page-level handlers +- `Engine.Handler` field - No 1:1 Handler coupling +- `page.Component` - Replaced by `Island` +- `MountHandler` (page-level) - Islands have their own mount +- `RenderHandler` (page-level) - Islands render themselves +- `Socket.Assigns()` returning full page state - Islands get scoped state only + +### Removed Patterns +- Full-page render cycle +- Single Handler per route +- `http.Handle("/", live.NewHttpHandler(h))` pattern +- Template-based full-page rendering +- `page.WithComponentMount()` configuration +- Position-based anchoring (`_l_0_1_0`) +- Hardcoded WebSocket transport (now abstracted) + +### New in v2 +- Transport abstraction (WebSocket, SSE, long-polling) +- Island-scoped anchoring +- Custom element client API +- Props-based configuration +- Central island registry +- Shared connection with message routing + +### Removed Files (v1) +- `page/component.go` - v1 component abstraction +- `page/configuration.go` - v1 component configs +- `page/render.go` - v1 component rendering helpers +- Examples using full-page pattern + +## v2 Architecture + +Based on the design decisions, here is the v2 target architecture: + +### Server-Side Components + +``` + ┌─────────────────┐ + │ Island Registry │ + │ (global map) │ + └────────┬────────┘ + │ + ┌──────────────────────────────────┼──────────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Island: counter │ │ Island: clock │ │ Island: chat │ +│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ +│ │ Handler │ │ │ │ Handler │ │ │ │ Handler │ │ +│ │ (own copy) │ │ │ │ (own copy) │ │ │ │ (own copy) │ │ +│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + + ┌────────────────────────────────┐ + │ Island Engine │ + │ (transport-agnostic manager) │ + │ - Routes events by island ID │ + │ - Manages island state scopes │ + │ - Handles multiplexed patches │ + └────────┬───────────────────────┘ + │ + ┌────────┴────────┐ + │ Transport │ + │ Abstraction │ + └────────┬────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────┐ ┌───────────┐ ┌───────────┐ + │ WebSocket │ │ SSE │ │ Polling │ + │ Transport │ │ Transport │ │ Transport │ + └───────────┘ └───────────┘ └───────────┘ + │ │ │ + └───────────────────┼───────────────────┘ + │ + ┌────────▼────────────────────────┐ + │ Island State Store │ + │ key: sessionID:islandID │ + │ value: island-specific state │ + └─────────────────────────────────┘ +``` + +### Client-Side Components + +```html + + + +

My App

+ + + + + 0 + + + + + + + + + +
+ + + +
+
+ + + + +``` + +### Event Protocol + +Current protocol: +```json +{"t": "click-event", "i": 1, "d": {"key": "value"}} +``` + +Proposed protocol with island scoping: +```json +{ + "t": "click-event", + "i": 1, + "island": "counter-1", + "d": {"key": "value"} +} +``` + +### Patch Protocol + +Current patch targeting by position anchor: +```json +{"Anchor": "_l_0_1_0", "Action": 1, "HTML": "5"} +``` + +Proposed patch with island scoping: +```json +{ + "island": "counter-1", + "patches": [ + {"Anchor": "_l_0", "Action": 1, "HTML": "5"} + ] +} +``` + +### v2 Server API + +**Island Registration:** +```go +// Register islands at startup +live.RegisterIsland("counter", func() *Island { + return &Island{ + Mount: counterMount, + Render: counterRender, + Events: map[string]EventHandler{ + "inc": incrementHandler, + "dec": decrementHandler, + }, + } +}) + +// Create engine with transport options +engine := live.NewEngine( + live.WithWebSocket(), // Enable WebSocket transport + live.WithSSE(), // Enable SSE transport + live.WithPolling(), // Enable long-polling fallback +) + +// Serve transport endpoints +http.Handle("/_live/ws", engine.WebSocketHandler()) +http.Handle("/_live/sse", engine.SSEHandler()) +http.Handle("/_live/poll", engine.PollingHandler()) +http.Handle("/live.js", live.Javascript{}) +``` + +**Island Definition:** +```go +type Island struct { + // Mount called when island connects + Mount func(ctx context.Context, props Props) (State, error) + + // Render called to generate island HTML + Render func(state State) (io.Reader, error) + + // Events map of event handlers + Events map[string]EventHandler + + // Children for nested island support + Children map[string]*Island +} + +type EventHandler func(ctx context.Context, state State, params Params) (State, error) + +// Transport abstraction +type Transport interface { + // Send patches to client + Send(ctx context.Context, sessionID string, patches []Patch) error + + // Receive events from client (channel-based) + Events() <-chan Event + + // Close the transport + Close() error +} + +// Specific transport implementations +type WebSocketTransport struct { /* ... */ } +type SSETransport struct { /* ... */ } +type PollingTransport struct { /* ... */ } +``` + +**Client Custom Element:** +```typescript +class LiveIsland extends HTMLElement { + private transport: Transport; + private islandId: string; + private islandType: string; + + async connectedCallback() { + this.islandId = this.getAttribute('id'); + this.islandType = this.getAttribute('type'); + + // Negotiate transport (try WebSocket, fallback to SSE, then polling) + this.transport = await TransportNegotiator.connect({ + prefer: 'websocket', + fallback: ['sse', 'polling'] + }); + + this.transport.subscribe(this.islandId, this.handlePatch.bind(this)); + this.wireEvents(); + } + + disconnectedCallback() { + this.transport.unsubscribe(this.islandId); + } + + private wireEvents() { + // Only wire events within this island's DOM + this.querySelectorAll('[live-click]').forEach(el => { + el.addEventListener('click', (e) => { + this.transport.send({ + t: el.getAttribute('live-click'), + island: this.islandId, + d: this.collectParams(el) + }); + }); + }); + } + + private handlePatch(patches: Patch[]) { + // Apply patches only within this island + patches.forEach(p => { + const target = this.querySelector(`[${p.Anchor}]`); + if (target) target.outerHTML = p.HTML; + }); + this.wireEvents(); // Re-wire after patch + } +} + +customElements.define('live-island', LiveIsland); + +// Transport negotiation +class TransportNegotiator { + static async connect(options: TransportOptions): Promise { + // Try WebSocket first + if (options.prefer === 'websocket' || options.fallback.includes('websocket')) { + try { + return await WebSocketTransport.connect(); + } catch (e) { + console.warn('WebSocket unavailable, falling back'); + } + } + + // Try SSE + if (options.fallback.includes('sse')) { + try { + return await SSETransport.connect(); + } catch (e) { + console.warn('SSE unavailable, falling back'); + } + } + + // Fallback to polling + return await PollingTransport.connect(); + } +} +``` + +### Nesting Implementation + +For nested islands, the parent island renders child island elements: + +```go +func dashboardRender(state DashboardState) (io.Reader, error) { + return template.Execute(` +
+

{{.Title}}

+ {{range .Charts}} + + + + {{end}} +
+ `, state) +} +``` + +The client handles nesting naturally: +1. Parent `` initializes +2. During parent render, child `` elements are added to DOM +3. Browser's custom element lifecycle calls `connectedCallback` on children +4. Each child subscribes independently to the shared socket + +## What v2 Enables (That v1 Cannot) + +1. **Embed islands in static sites**: Drop `` into any HTML without full-page takeover +2. **Mix frameworks**: Use Live islands alongside React, Vue, or vanilla JS on the same page +3. **Selective interactivity**: Static content stays static, only islands are interactive +4. **True component reuse**: Same island type multiple times with different props +5. **Progressive enhancement**: Islands can enhance existing server-rendered HTML +6. **Smaller payload**: Only load island code that's actually used on the page +7. **Independent updates**: One island can update without touching others +8. **Easier testing**: Test islands in isolation without page context +9. **Transport flexibility**: Choose WebSocket, SSE, or polling based on environment +10. **Better proxy compatibility**: SSE works through proxies that block WebSockets +11. **Read-heavy optimization**: SSE is more efficient for server-to-client only updates +12. **Graceful degradation**: Auto-fallback to compatible transport + +### v2 Implementation Approach + +Clean rewrite removing all v1 concepts: + +1. **Remove full-page concepts entirely** + - Delete `Handler.MountHandler`, `Handler.RenderHandler` (page-level handlers) + - Delete `Engine` as page-level connection manager + - Remove `page/component.go` (v1 component abstraction) + - Remove hardcoded WebSocket implementation + +2. **New core types** + - `Island` - Self-contained interactive component + - `IslandRegistry` - Global registry mapping island types to constructors + - `IslandEngine` - Manages shared connection and routes to islands + - `Transport` - Interface for WebSocket/SSE/polling implementations + - `Session` - Scoped session per client (transport-agnostic) + +3. **Protocol changes** + - Add `island` field to all events + - Scope patches by island ID + - Island-scoped anchors (not position-based) + - Transport-agnostic message format + +4. **Transport layer** + - Implement `Transport` interface + - WebSocket transport (primary) + - SSE transport with HTTP POST for events + - Optional polling transport + - Client auto-negotiation with fallback + +5. **Client rewrite** + - Custom element as primary API + - Transport abstraction with auto-negotiation + - Island-scoped event wiring and patching + - Shared connection with island subscriptions + +6. **Examples as v2 showcase** + - All examples use island pattern + - Show composition, nesting, props + - Demonstrate state isolation + - Examples using different transports + +## v2 Implementation Questions + +The following questions remain for v2 implementation planning: + +### 1. Anchor Generation Strategy +**Question**: How should anchors be scoped in v2? + +**Options**: +- Island-scoped: `_i_counter-1_0_1` (scoped to island instance) +- Type-scoped: `_t_counter_0_1` (same across all instances of island type) +- Hybrid: Island ID prefix + relative path + +**Recommendation**: Island-scoped (`_i_counter-1_0_1`) for stability and isolation + +### 2. State Storage Schema +**Question**: How should island state be keyed in the store? + +**Options**: +- Composite key: `socketID:islandID` +- Nested structure: `{socketID: {islands: {islandID: state}}}` +- Separate stores per island type + +**Recommendation**: Composite key for simplicity, single SocketStateStore + +### 3. Event Protocol Format +**Question**: Should island ID be in event payload or as WebSocket subprotocol? + +**Options**: +- In JSON payload: `{t: "inc", island: "counter-1", d: {}}` +- As message envelope: `{island: "counter-1", event: {t: "inc", d: {}}}` +- URL-based routing: `/ws/counter-1` (separate connections) + +**Recommendation**: JSON payload for single connection multiplexing + +### 4. Server-Side Rendering +**Question**: How should islands render their initial HTML? + +**Options**: +- Server renders at page load, custom element hydrates +- Empty custom element, island renders after connect +- Hybrid: static shell + live hydration + +**Recommendation**: Server renders initial HTML, custom element connects for interactivity + +### 5. Nested Island Communication +**Question**: How should parent-child islands communicate? + +**Options**: +- Direct method calls (parent has child reference) +- Event bubbling through DOM +- Explicit events via shared socket +- Props down, events up (React pattern) + +**Recommendation**: Props down + explicit events up for predictability + +### 6. Island Lifecycle Hooks +**Question**: What lifecycle hooks should islands expose? + +**Proposed**: +- `Mount(ctx, props)` - Initialize state +- `Update(ctx, state, props)` - Props changed +- `HandleEvent(ctx, state, event, params)` - User event +- `HandleSelf(ctx, state, event, data)` - Server event +- `Unmount(ctx, state)` - Cleanup + +### 7. TypeScript Client API +**Question**: Should client provide imperative API beyond custom elements? + +**Options**: +- Custom element only (declarative) +- `new LiveIsland({type, props})` (imperative) +- Both (use case dependent) + +**Recommendation**: Custom element primary, imperative for programmatic use + +### 8. Hot Reload Support +**Question**: How should v2 support hot reload during development? + +**Options**: +- Re-register island on file change, reconnect all instances +- Send reload event, islands re-mount +- Full page reload (simplest) + +**Recommendation**: Island-level re-mount for fast iteration + +### 9. Transport Implementation Details +**Question**: How should different transports be implemented? + +**WebSocket Implementation**: +```go +// Full-duplex, bidirectional +type WebSocketTransport struct { + conn *websocket.Conn + send chan Message + recv chan Event +} + +// Server to client: patches via ws.Write() +// Client to server: events via ws.Read() +``` + +**SSE Implementation**: +```go +// Server to client: SSE stream +// Client to server: HTTP POST +type SSETransport struct { + writer http.ResponseWriter + flusher http.Flusher + eventEndpoint string // POST endpoint for client events +} + +// Server to client: SSE events +// Client to server: fetch('/_live/sse/event', {method: 'POST', body: event}) +``` + +**Polling Implementation**: +```go +// Client polls for updates +type PollingTransport struct { + pollInterval time.Duration + lastEventID string +} + +// Server: Long-poll endpoint returns patches when available +// Client: Poll /_live/poll?lastEventId=123, POST events to /_live/poll/event +``` + +**Transport Selection Algorithm**: +1. Client attempts WebSocket connection +2. If fails (timeout, 4xx/5xx), try SSE +3. If SSE fails, fallback to polling +4. Once connected, stick with transport (don't switch mid-session) + +**Transport Use Cases**: +- **WebSocket**: Interactive islands with frequent bidirectional updates (chat, collaborative editing) +- **SSE**: Read-heavy islands with server-driven updates (dashboards, live feeds, notifications) +- **Polling**: Fallback for restrictive networks or legacy browser support + +**SSE Benefits**: +- Automatic reconnection built into EventSource API +- Works through most HTTP proxies and firewalls +- Simpler server implementation (standard HTTP response) +- Lower overhead for server-to-client only scenarios +- Native browser retry with exponential backoff + +**Recommendation**: Implement WebSocket first, SSE second (high priority), polling as optional fallback + +## Conclusion + +Live v2 represents a fundamental architectural shift from full-page live views to a pure islands architecture. This change aligns the library with modern web development patterns (Astro Islands, React Server Components, etc.) while maintaining Go's server-side strengths. + +### Key v2 Principles + +1. **Islands are the primitive**: No concept of pages, only reusable islands +2. **Isolation by default**: Each island is a self-contained unit +3. **Shared infrastructure**: Single connection efficiently multiplexes island messages +4. **Transport flexibility**: WebSocket, SSE, or polling based on environment +5. **Web standards**: Custom elements provide natural browser integration +6. **Developer familiarity**: Props, nesting, and composition match React/Vue patterns + +### Next Steps + +1. ✅ Create v2 branch (already done) +2. Define Transport interface +3. Implement WebSocket transport +4. Implement core Island type and registry +5. Build IslandEngine with message routing and transport abstraction +6. Develop custom element client with transport negotiation +7. Implement SSE transport +8. Port one example (counter) to v2 pattern +9. Add examples using different transports +10. Iterate on API based on practical usage +11. Document migration guide for v1 users +12. Release v2.0.0 as breaking major version + +**Implementation Priority**: +- **Phase 1**: WebSocket transport + basic island system (MVP) +- **Phase 2**: SSE transport (production-ready) +- **Phase 3**: Polling transport (optional, for edge cases) + +### Breaking Change Philosophy + +v2 is unapologetically incompatible with v1. This clean break enables: +- Better architecture without legacy constraints +- Simpler codebase without backward compatibility code +- Clear mental model (islands only) +- Modern patterns aligned with ecosystem trends + +Users who need v1 can pin to `v1.x` tags. v2 is the future. diff --git a/docs/research/2026-02-22-v2-branch-state-and-testing-gaps.md b/docs/research/2026-02-22-v2-branch-state-and-testing-gaps.md new file mode 100644 index 0000000..bbe2070 --- /dev/null +++ b/docs/research/2026-02-22-v2-branch-state-and-testing-gaps.md @@ -0,0 +1,417 @@ +--- +date: 2026-02-22T12:00:00+00:00 +researcher: Josh +topic: "Live v2 Branch: Current State, Testing Completeness, and Remaining Work" +tags: [research, v2, testing, gaps, branch-state, wire-format, session-ids, protobuf] +last_updated: 2026-02-22 +last_updated_by: Josh +last_updated_note: "Resolved all 4 open questions: wire format, subscribe protocol, session IDs, polling transport. Added wire format contract analysis." +--- + +# Research: Live v2 Branch State and Remaining Work + +## Research Question + +What is the current state of the v2 branch? Is testing complete? What else needs doing? + +## Summary + +The v2 islands-only architecture is **structurally complete** on the server side. All 25 planned deliverables from the implementation plan are marked done. The Go server compiles cleanly, passes 283 tests with race detection at 82.2% coverage, and has no vet issues. + +However, there are **significant gaps between the server and client integration**: + +1. The counter example uses a hand-written `custom-island.js` instead of the built v2 client library (`auto.js`) +2. The v2 client library (transport negotiation, island-scoped patching, hooks) has **never been verified working with the Go server** +3. 8 client tests fail in `negotiator.spec.ts` (transport fallback scenarios) +4. There are uncommitted changes (error logging improvements) and untracked test files +5. Only 1 example exists (counter) - no nested islands, forms, broadcasts, or hooks demos +6. **Session ID handling is incompatible between server and client for WebSocket** (see Resolved Questions) + +## Detailed Findings + +### Go Server: Complete and Tested + +All 19 Go source files implement the v2 architecture: + +| File | Purpose | Tests | +|------|---------|-------| +| `island.go` | Island definition, handlers, Props | `island_test.go` (10 tests) | +| `instance.go` | Runtime island instances | `instance_test.go` (11 tests) | +| `registry.go` | Thread-safe island registry | `registry_test.go` (8 tests) | +| `engine.go` | IslandEngine orchestrator | `engine_test.go` (7 tests) | +| `session.go` | Transport-agnostic sessions | `session_test.go` (11 tests) | +| `transport.go` | Transport interface | `transport_test.go` (11 tests) | +| `transport_websocket.go` | WebSocket transport | `transport_websocket_test.go` (8 tests) | +| `transport_sse.go` | SSE transport | `transport_sse_test.go` (9 tests) | +| `statestore.go` | Island state persistence | `statestore_test.go` (12 tests) | +| `diff.go` | HTML diffing with island anchors | `diff_test.go` (17 tests) | +| `render.go` | Island rendering pipeline | `render_test.go` (4 tests) | +| `event.go` | Event protocol | `event_test.go` (2 tests) | +| `params.go` | Parameter parsing | `params_test.go` (5 tests) | +| `context.go` | HTTP context utilities | `context_test.go` (6 tests, untracked) | +| `types.go` | IslandID, SessionID aliases | N/A (type aliases) | +| `errors.go` | Error definitions | N/A (constants) | +| `http.go` | HTTP helpers | Tested indirectly | +| `transport_endpoints.go` | HTTP endpoint factories | Tested indirectly | +| `javascript.go` | JS serving handlers | Not tested | + +When `go test -race ./...` runs, all 283 tests pass with 82.2% statement coverage. + +### Client Library: Complete but Not Integration-Tested + +The TypeScript client in `web/src/` is a full v2 rewrite: + +| File | Purpose | Tests | +|------|---------|-------| +| `island.ts` | `` custom element | `island.spec.ts` | +| `connection.ts` | ConnectionManager singleton | `connection.spec.ts` | +| `events.ts` | Island-scoped event wiring | `events.spec.ts` | +| `patch.ts` | Island-scoped patch application | `patch.spec.ts` | +| `hooks.ts` | Island-aware hook registry | `hooks.spec.ts` | +| `transport/websocket.ts` | WebSocket transport | `websocket.spec.ts` | +| `transport/sse.ts` | SSE transport | `sse.spec.ts` | +| `transport/negotiator.ts` | Auto transport selection | `negotiator.spec.ts` (FAILING) | +| `transport/message.ts` | Wire message types | N/A | +| `transport/transport.ts` | Transport interface | N/A | +| `auto.ts` | Auto-init entry point | N/A | +| `event.ts` | Event dispatch, lifecycle | N/A | +| `forms.ts` | Form state preservation | N/A | +| `element.ts` | Element helpers | N/A | +| `interop.ts` | Backward compat interfaces | N/A | + +**Client test results**: 197 pass, 8 fail. All 8 failures are in `negotiator.spec.ts`: +- `should fallback to SSE when WebSocket fails` +- `should track all failed transport types` +- `should timeout and fallback if WebSocket takes too long` +- `should reject when all transports fail` +- `should include failed transport types in error` +- `should pass custom SSE endpoint to transport` +- `should use default endpoints when not specified` +- `should close failed transports during negotiation` + +The failures are all related to the WebSocket-to-SSE fallback path, where `"WebSocket connection failed"` is thrown during negotiation cleanup. This suggests a timing/cleanup issue in the negotiator's error handling path. + +### Counter Example: Uses Custom JS, Not the Library + +The counter example (`examples/counter/`) works but has a critical architectural gap: + +- `index.html:117` loads `/custom-island.js` (hand-written, 157 lines) +- It does NOT load `auto.js` (the built v2 client library) +- The custom JS implements a simplified island model: + - Simple `innerHTML` replacement (`custom-island.js:130`) instead of anchor-based patching + - No transport negotiation (WebSocket only) + - No hook support + - No form state preservation + - No debounce or loading states + - No lifecycle events + +This means the entire v2 client library - transport negotiation, island-scoped patching, hooks, ConnectionManager, EventWiring - has **never been tested against the actual Go server**. + +### Uncommitted Changes + +**Modified files** (unstaged): +- `engine.go`: 3 changes upgrading silent error suppression to `slog.Error` logging in `renderAndSendIsland`, `BroadcastToIslandType`, `BroadcastToIsland` +- `transport_websocket.go`: 1 change adding `slog.Error` logging when events channel is full and an event is dropped + +**Untracked files**: +- `context_test.go`: 6 tests for HTTP context utilities (170 lines) +- `examples_test.go`: Example documentation for broadcast operations (14 lines, no actual test functions) + +### v1 Backup Directory + +The `_v1_backup/` directory contains archived v1 files: +- `handler.go.v1`, `socket.go.v1`, `pubsub.go.v1`, `upload.go.v1`, `socketstate.go.v1` +- `page.v1/component.go`, `page.v1/configuration.go`, `page.v1/render.go` +- Test files: `handler_test.go.v1`, `example_test.go.v1`, `socketstate_test.go.v1`, `page.v1/example_test.go` + +This directory serves no functional purpose on the v2 branch. The v1 code is preserved in git history and on the master branch. + +## Resolved Questions + +### 1. Wire Format Compatibility: COMPATIBLE (but fragile) + +The Go server and TypeScript client wire formats match: + +| Field | Go JSON Output | TypeScript Expects | Match | +|-------|---------------|-------------------|-------| +| `Event.T` | `"t"` (json tag) | `t` | Yes | +| `Event.Island` | `"island"` (json tag) | `island?` | Yes | +| `Event.Data` | `"d"` (json tag) | `d?` | Yes | +| `Patch.Anchor` | `"Anchor"` (no json tag) | `Anchor` | Yes | +| `Patch.Action` | `"Action"` (no json tag, number) | `Action` (PatchAction enum) | Yes | +| `Patch.HTML` | `"HTML"` (no json tag) | `HTML` | Yes | +| `Patch.IslandID` | `"island_id"` (json tag) | `island_id?` | Yes | + +**Example server output:** +```json +{ + "t": "patch", + "island": "counter-1", + "d": [ + {"Anchor": "_i_counter-1_0", "Action": 1, "HTML": "5", "island_id": "counter-1"} + ] +} +``` + +**Fragility risk:** The `Patch` struct's `Anchor`, `Action`, and `HTML` fields have **no json struct tags** (`diff.go:124-139`). Go defaults to the capitalized field name, which happens to match TypeScript. But adding a `json:"anchor"` tag would silently break the client. This should be made intentional by adding explicit json tags that match the current behavior. + +### 2. Subscribe Protocol: COMPATIBLE + +Both custom-island.js and the v2 client library send identical subscribe messages: + +```json +{"t": "subscribe", "island": "counter-1", "d": {"type": "counter"}} +``` + +**Client library flow** (`connection.ts:203-218`): +1. `LiveIsland.connectedCallback()` calls `connectionManager.registerIsland(id, type, handler)` +2. ConnectionManager calls `subscribeIsland(islandId, islandType)` +3. Sends `{t: "subscribe", island: islandId, d: {type: islandType}}` + +**One difference:** The v2 client also sends `{t: "unsubscribe", island: islandId}` on disconnect (`connection.ts:224-237`). The server has no handler for this - events are silently ignored. Island state persists until TTL expiration. This is safe but could be improved. + +**Server connect event:** The WebSocket transport sends `{t: "connect"}` after upgrade (`transport_websocket.go:299-303`). The v2 client receives this via its message routing but doesn't depend on it for subscription - subscription is triggered by island registration, not connect events. + +### 3. Session ID Alignment: INCOMPATIBLE for WebSocket + +This is a real bug that prevents reconnection from restoring state. + +**Server behavior** (`examples/counter/main.go:132`): +```go +sessionID := live.SessionID(fmt.Sprintf("session-%d", time.Now().UnixNano())) +``` +Server generates a new timestamp-based session ID per WebSocket connection. The client's cookie is ignored. + +**Client behavior** (`web/src/transport/websocket.ts:33-49`): +- Reads or generates a UUID v4 session ID +- Stores in `live_session` cookie (60-second TTL) +- Expects the same session to persist across reconnections + +**Result:** Every WebSocket connection gets a new server-side session ID. On reconnect, the client sends the same cookie, but the server creates a fresh `session-{timestamp}` and mounts new island instances. State is lost. + +**SSE is partially better:** The server has `getSessionIDFromRequest()` (`transport_sse.go:391-405`) that reads the `live_session` cookie or `X-Live-Session` header. However, the counter example doesn't use it - it also generates a timestamp-based ID (`main.go:196`). + +**Fix needed:** The server's connection handlers should read the session ID from the client's `live_session` cookie (available on the HTTP upgrade request), falling back to generating one only if no cookie exists. This applies to both WebSocket and SSE handlers. + +### 4. Polling Transport: Intentionally Deferred + +The server has no polling transport implementation. The client has `TransportType.Polling` in the negotiator enum but no `PollingTransport` class. The implementation plan lists it as "Optional Deliverable." WebSocket + SSE covers virtually all environments, so this is a reasonable deferral. + +## Wire Format Contract Analysis + +### Current State: JSON with No Formal Schema + +The wire protocol uses 6 system message types plus user-defined event names: + +| Message Type | Direction | `d` Payload | +|-------------|-----------|-------------| +| `connect` | Server -> Client | None | +| `ack` | Server -> Client | Optional ID | +| `err` | Server -> Client | `{source: Event, err: string}` | +| `patch` | Server -> Client | `[]Patch` (Anchor, Action, HTML, island_id) | +| `params` | Bidirectional | URL params map | +| `redirect` | Server -> Client | URL string | +| User events (`inc`, `dec`, etc.) | Client -> Server | Arbitrary params | + +The Go side uses `json.RawMessage` for `Event.Data` and the TypeScript side uses `d?: any`. Neither enforces type safety on the payload. + +### Protobuf Assessment: Not Recommended + +**Why protobuf is a poor fit for this project:** + +1. **String-heavy payloads negate size advantage.** Patches carry raw HTML. For string data, protobuf is only ~84% of gzipped JSON (vs 16% for numeric data). WebSocket per-frame compression handles both equally. + +2. **SSE is text-only.** Binary protobuf over SSE requires base64 encoding, negating size savings and adding complexity. + +3. **User-defined event names can't be captured in a `.proto` enum.** The `T` field is open-ended by design. Protobuf would only type-check the 6 system message types, not the most important direction (client-to-server user events). + +4. **Dependency bloat.** Would add `buf`/`protoc` binary + `@bufbuild/protobuf` npm runtime dep to a project that currently has 3 Go deps and minimal npm deps. + +5. **Ecosystem precedent.** Phoenix LiveView shipped JSON after years of development (considered BERT binary, never shipped it). Hotwire/Turbo sends raw HTML over WebSocket. HTMX uses raw HTML fragments. None use binary wire formats. + +### Recommended Approach: Explicit JSON Tags + Typed TypeScript + +**Tier 1 (do now):** Make the contract intentional without new tooling. + +On the Go side, add explicit json tags to the `Patch` struct so the field names are a deliberate choice: +```go +type Patch struct { + Anchor string `json:"Anchor"` + Action PatchAction `json:"Action"` + HTML string `json:"HTML"` + IslandID string `json:"island_id,omitempty"` +} +``` + +On the TypeScript side, replace `d?: any` in `TransportMessage` with a discriminated union: +```typescript +interface PatchMessage { t: "patch"; island: string; d: Patch[]; } +interface ErrorMessage { t: "err"; d: { source: TransportMessage; err: string }; } +interface ConnectMessage { t: "connect"; } +// etc. +type ServerMessage = PatchMessage | ErrorMessage | ConnectMessage | ...; +``` + +**Tier 2 (consider later):** If more message types are added, use **JSON Typedef** (`jtd-codegen`) to generate both Go structs and TypeScript interfaces from a single `.jtd.json` schema file. No wire format change, no runtime deps, single binary tool. Gives compile-time guarantee that both sides match. + +## What Needs Doing + +### Critical: Client-Server Integration + +1. **Fix session ID handling** - Server WebSocket/SSE handlers must read session ID from the client's `live_session` cookie on the HTTP upgrade request, falling back to generating one only if none exists. Without this, reconnection cannot restore state. + +2. **Connect the real client library to the counter example** - Replace `custom-island.js` with `auto.js`. This requires verifying that the `` custom element, ConnectionManager, transport negotiation, and island-scoped patching all work together with the Go server's event routing and patch generation. + +3. **Fix the 8 failing negotiator tests** - The `negotiator.spec.ts` failures indicate a bug in the WebSocket-to-SSE fallback path. Since transport negotiation is a core feature, this needs fixing before the client can be considered complete. + +4. **Add explicit json tags to Patch struct** - The current compatibility is accidental (Go defaults to capitalized field names). Adding `json:"Anchor"` etc. makes the contract intentional and prevents future breakage. + +### Important: Additional Testing + +5. **End-to-end browser test** - Run the counter example with the real client library and verify in a browser that: + - Islands mount and render initial state from server + - Click events route to correct island + - Patches apply correctly within island boundaries + - Multiple islands operate independently + - Reconnection works (kill server, restart, verify state) + +6. **SSE transport end-to-end** - The SSE transport has server tests and client tests but no end-to-end verification. The counter example serves SSE endpoints but the custom JS doesn't use them. + +### Recommended: Examples and Cleanup + +7. **More examples** demonstrating: + - Nested/composed islands (parent renders child `` elements) + - Form handling within islands (text inputs, checkboxes with state preservation) + - Server-to-client broadcasts (`BroadcastToIslandType`, `BroadcastToIsland`) + - Hook usage (`live-hook` attribute with lifecycle callbacks) + - SSE-only island (dashboard/feed pattern) + +8. **Remove `_v1_backup/` directory** - v1 code is in git history; no need to keep archived copies on the v2 branch. + +9. **Commit uncommitted changes** - The error logging improvements in `engine.go` and `transport_websocket.go` plus the new `context_test.go` should be committed. + +10. **Tighten TypeScript types** - Replace `d?: any` in `TransportMessage` with discriminated union types per message type. + +### Optional: Coverage Gaps + +11. **`transport_endpoints.go` tests** - The HTTP handler factories (`WebSocketHandler`, `SSEHandler`, `UpgradeSSE`) are not directly tested. They're thin wrappers exercised indirectly by integration tests, but direct tests would improve coverage. + +12. **`javascript.go` tests** - The JS/sourcemap serving handlers are untested. + +## Code References + +- `engine.go` - IslandEngine with uncommitted slog.Error improvements +- `transport_websocket.go` - WebSocket transport with uncommitted event drop logging +- `context_test.go` - New untracked test file +- `examples_test.go` - New untracked doc example file +- `examples/counter/custom-island.js` - Hand-written JS bypassing the v2 client library +- `examples/counter/index.html:117` - Loads custom-island.js instead of auto.js +- `examples/counter/main.go:132` - Server generates timestamp-based session IDs (incompatible with client) +- `web/src/transport/negotiator.spec.ts` - 8 failing tests +- `web/src/transport/websocket.ts:33-49` - Client generates UUID session IDs in cookie +- `web/src/transport/message.ts` - Wire message types with `d?: any` (untyped) +- `web/browser/auto.js` - Built v2 client library (19KB minified) +- `diff.go:124-139` - Patch struct with no json tags (fragile) +- `transport_sse.go:391-405` - `getSessionIDFromRequest()` reads cookie/header +- `event.go:45-65` - Event struct with json tags +- `_v1_backup/` - Archived v1 code (can be removed) + +## Architecture Documentation + +### Current v2 Data Flow (Server) + +When a client event arrives: +1. Transport receives JSON message with `island` field +2. `engine.RouteEvent()` looks up session, finds island instance +3. Island's `CallEvent()` updates state +4. `renderAndSendIsland()` calls `RenderIsland()` with new state +5. `DiffIsland()` compares against `lastRenderedHTML` +6. Patches sent via `session.Send()` through transport + +### Current v2 Data Flow (Counter Example Client - custom-island.js) + +1. Custom element `connectedCallback` sends `subscribe` message +2. Server mounts island, renders initial HTML, sends patch +3. Client receives patch, does `island.innerHTML = message.d.html` (full replacement) +4. Click events send `{t: eventType, island: id, d: {}}` via WebSocket +5. Server routes event, updates state, sends new patch + +### Expected v2 Data Flow (Library Client - auto.js) + +1. `` custom element `connectedCallback` registers with ConnectionManager +2. ConnectionManager negotiates transport (WebSocket -> SSE fallback) +3. Sends subscribe via transport +4. Server mounts island, renders, sends patches +5. `applyIslandPatches()` applies anchor-based patches within island DOM +6. `wireIslandEvents()` wires `live-click`/`live-submit`/etc. within island +7. Events sent via ConnectionManager through negotiated transport +8. Hooks executed on mount/update/destroy lifecycle + +### Session ID Flow (Current - Broken) + +``` +Client Server + | | + |-- WebSocket Upgrade --------->| + | (Cookie: live_session=UUID) | + | |-- Ignores cookie + | |-- Generates session-{timestamp} + |<-- {t: "connect"} -----------| + | | + |-- {t: "subscribe"} --------->|-- MountIsland(session-{timestamp}, ...) + | | + | ... connection drops ... | + | |-- DeleteSession(session-{timestamp}) + | | + |-- WebSocket Upgrade --------->| + | (Cookie: live_session=UUID) | (same UUID!) + | |-- Ignores cookie AGAIN + | |-- Generates session-{NEW timestamp} + | |-- State from old session is LOST +``` + +### Session ID Flow (Fixed) + +``` +Client Server + | | + |-- WebSocket Upgrade --------->| + | (Cookie: live_session=UUID) | + | |-- Reads cookie: UUID + | |-- Uses UUID as SessionID + |<-- {t: "connect"} -----------| + | | + |-- {t: "subscribe"} --------->|-- MountIsland(UUID, ...) + | | + | ... connection drops ... | + | |-- Keeps session in store (TTL) + | | + |-- WebSocket Upgrade --------->| + | (Cookie: live_session=UUID) | (same UUID!) + | |-- Reads cookie: UUID + | |-- Finds existing session + | |-- Restores island state from store +``` + +## External Context + +### Wire Format Precedent in LiveView-Style Frameworks + +- **Phoenix LiveView**: JSON arrays over WebSocket. Custom diff protocol. Considered BERT binary encoding (community project showed 2-10x faster encoding) but never shipped it in mainline. Chose JSON pragmatism. +- **Hotwire/Turbo**: Raw HTML strings wrapped in `` XML tags over WebSocket/SSE. Zero schema contract. +- **HTMX**: Raw HTML fragments. No wire format contract at all. +- **Conclusion**: None of the major server-rendered real-time frameworks use binary wire formats. JSON (or raw HTML) is the standard. + +### Schema Contract Options (Evaluated) + +| Approach | Effort | Value for Live v2 | New Dependencies | +|----------|--------|-------------------|------------------| +| Explicit json tags + TS union types | Low | High - prevents accidental breakage | None | +| JSON Typedef (`jtd-codegen`) | Moderate | Good - compile-time guarantee | `jtd-codegen` binary (build only) | +| Protobuf (`buf` + `protobuf-es`) | High | Modest - string-heavy payloads negate size advantage | `buf` binary, `@bufbuild/protobuf` npm | +| Flatbuffers | Very high | Minimal - complex API, larger wire format | Multiple | + +## Historical Context (from docs/) + +- `docs/research/2026-01-25-islands-component-architecture.md` - Original v2 architecture research +- `docs/plans/v2-islands-architecture.md` - Implementation plan with 25 deliverables (all marked complete) diff --git a/docs/research/2026-02-25-v2-examples-porting.md b/docs/research/2026-02-25-v2-examples-porting.md new file mode 100644 index 0000000..e306b28 --- /dev/null +++ b/docs/research/2026-02-25-v2-examples-porting.md @@ -0,0 +1,376 @@ +--- +date: 2026-02-25T14:00:00Z +researcher: josh +topic: "Porting master branch examples to v2 islands architecture" +tags: [research, examples, v2, islands, porting] +last_updated: 2026-02-25 +last_updated_by: josh +--- + +# Research: Porting Master Branch Examples to v2 Islands Architecture + +## Research Question + +What examples exist on the master branch, what features do they demonstrate, and what is needed to create v2 versions using the islands architecture? + +## Summary + +The master branch has 14 example directories. After deduplication (chat is a library used by cluster; components is a library used by clocks; chart has no Go code), there are **10 distinct examples** showcasing different framework features. The existing v2 branch has only a **counter** example. + +Seven examples can be ported directly using features that already exist in v2. Three examples use v1 features that do **not yet exist** in v2: `live-window-keyup` (buttons), `HandleParams`/`live-patch`/`PatchURL` (pagination), and `AllowUploads`/`ConsumeUploads` (uploads). + +## Detailed Findings + +### Master Branch Examples Inventory + +| # | Example | Feature Demonstrated | V1 API Used | +|---|---------|---------------------|-------------| +| 1 | **buttons** | Counter with keyboard shortcuts | `HandleEvent`, `live-click`, `live-window-keyup`, `live-key` | +| 2 | **todo** | Form validation, list management, checkboxes | `HandleEvent`, `live-change`, `live-submit`, `live-debounce`, `live-value-*`, `Params.Checkbox()` | +| 3 | **clock** | Server-pushed timed updates | `HandleSelf`, `s.Self()`, goroutine scheduling | +| 4 | **chat** | Broadcasting, `live-update="append"`, hooks | `HandleEvent`, `HandleSelf`, `s.Broadcast()`, `live-update="append"`, `live-hook`, `live-submit` | +| 5 | **alpine** | Integration with Alpine.js | `HandleEvent`, `live-submit`, `live-change`, `live-click`, `live-value-*` | +| 6 | **error** | Error handling, hooks, `handleEvent` in JS | `HandleEvent`, `ErrorHandler`, `live-hook`, `live-value-*` | +| 7 | **pagination** | URL parameter handling, client/server-side navigation | `HandleParams`, `live-patch`, `s.PatchURL()`, `live-click`, `live-value-*` | +| 8 | **prefill** | Form prefill, validation | `HandleEvent`, `live-change`, `live-submit` | +| 9 | **uploads** | File uploads with validation, progress | `AllowUploads`, `ConsumeUploads`, `live-change`, `live-submit`, `Upload.Progress` | +| 10 | **cluster** | PubSub broadcasting across handler instances | `NewPubSub`, `CloudTransport`, `Broadcast` | +| 11 | **clocks** | Component system (page/component API) | `page.WithComponentMount`, `page.WithComponentRenderer` | +| 12 | **components** | Reusable component library (used by clocks) | `page.Component`, clock widget | +| 13 | **chart** | No Go code — static HTML/JS only | N/A | +| 14 | **buttons** (duplicate listing — same as #1) | — | — | + +### Existing V2 Counter Example Pattern + +The counter example (`examples/counter/`) establishes the v2 pattern: + +**Server-side structure:** +1. Define state struct (`CounterState`) +2. Create island constructor (`NewCounterIsland`) returning `(*live.Island, error)` +3. Use `live.NewIsland("name", live.WithMount(...), live.WithRender(...))` +4. Register event handlers via `island.HandleEvent("name", handler)` +5. Register with global registry: `live.RegisterIsland("counter", NewCounterIsland)` +6. Create engine: `live.NewIslandEngine(ctx, registry, stateStore)` +7. Set up WebSocket/SSE transport endpoints with event loop +8. Serve index page and `live.Javascript{}` + +**HTML structure:** +- Index page uses `` custom elements +- Island template (re-rendered on state change) is simple HTML with `live-click` directives +- Initial content inside `` provides server-rendered fallback +- `` loads the client library + +**Event loop pattern (per transport):** +```go +for event := range transport.Events() { + if event.T == "subscribe" && event.Island != "" { + // Mount island with server-defined props + engine.MountIsland(sessionID, islandID, islandType, props) + } else { + engine.RouteEvent(sessionID, event) + } +} +``` + +### V2 Client-Side Capabilities + +The TypeScript client supports these event directives: + +| Directive | Wired To | Notes | +|-----------|----------|-------| +| `live-click` | `click` | Supported | +| `live-contextmenu` | `contextmenu` | Supported | +| `live-mousedown` | `mousedown` | Supported | +| `live-mouseup` | `mouseup` | Supported | +| `live-focus` | `focus` | Supported | +| `live-blur` | `blur` | Supported | +| `live-keydown` | `keydown` | Supported, with `live-key` filter | +| `live-keyup` | `keyup` | Supported, with `live-key` filter | +| `live-change` | `input` on forms | Serializes full form data | +| `live-submit` | `submit` on forms | Prevents default, serializes form | +| `live-debounce` | Per-element | Milliseconds or `"blur"` | +| `live-value-*` | Event params | Custom data attributes | +| `live-hook` | Hook lifecycle | `mounted`, `updated`, `destroyed`, etc. | + +**Not supported in v2 client:** +- `live-window-keyup` / `live-window-keydown` (window-level keyboard events) +- `live-patch` (client-side URL patching) +- File upload progress/validation UI + +**Server-side v2 capabilities:** +- `island.HandleEvent(name, handler)` — client events +- `island.HandleSelf(name, handler)` — server-targeted events +- `engine.BroadcastToIslandType(type, event)` — broadcast to all instances of a type +- `engine.BroadcastToIsland(islandID, event)` — broadcast to specific island ID +- `live-update="append|prepend|replace|ignore"` — server-driven patch actions + +### Porting Assessment Per Example + +#### Can Port Now (v2 features exist) + +**1. Todo** — Form validation, list, checkboxes +- Uses: `live-change`, `live-submit`, `live-debounce="blur"`, `live-value-*` +- All directives supported in v2 client +- `Params.String()` and `Params.Checkbox()` — need to verify `Params.Checkbox()` exists in v2 +- Port as single island + +**2. Clock** — Server-pushed timed updates +- Uses: `HandleSelf`, goroutine with `s.Self()` +- V2 has `island.HandleSelf(name, handler)` for self-events +- Need to verify how to send self-events from within a mount/event handler in v2 +- The v2 engine has no direct `Self()` equivalent on instance — events must be routed through the engine +- Port as single island, need mechanism to push self-events + +**3. Chat** — Broadcasting with `live-update="append"` +- Uses: `HandleEvent` (send), `HandleSelf` (newmessage), `s.Broadcast()`, `live-update="append"`, `live-hook`, `live-submit` +- V2 has `engine.BroadcastToIslandType()` for broadcasting +- V2 server supports `Append` patch action +- V2 client supports `live-hook` with `mounted` lifecycle +- Port as single island type, multiple instances share broadcasts + +**4. Alpine** — Alpine.js integration with autocomplete +- Uses: `live-submit`, `live-change`, `live-click`, `live-value-*` +- All directives supported in v2 +- Alpine.js integration is purely client-side, no framework dependency +- Port as single island + +**5. Error** — Error handling with hooks +- Uses: `live-click`, `live-value-*`, `live-hook`, `ErrorHandler` +- V2 client supports `live-hook` with `handleEvent` on hook context +- Need to verify v2 error handling mechanism (no `ErrorHandler` field on `Island`) +- Port as single island + +**6. Prefill** — Form prefill with validation +- Uses: `live-change`, `live-submit` +- All directives supported in v2 +- Port as single island + +**7. Cluster** — PubSub broadcasting +- V2 has `BroadcastToIslandType` built into the engine +- Multiple engine instances could share a PubSub layer +- Port as chat island with external PubSub adapter + +#### Cannot Port Yet (missing v2 features) + +**8. Buttons** — Keyboard shortcuts +- Requires `live-window-keyup` which is NOT in v2 client +- `live-click` and basic counter work fine +- Could port without keyboard shortcuts, or add `live-window-keyup` support to v2 client + +**9. Pagination** — URL parameter handling +- Requires `HandleParams` (not in v2 server API) +- Requires `live-patch` (not in v2 client) +- Requires `s.PatchURL()` (not in v2 server API) +- This is a significant feature gap — URL-driven state management + +**10. Uploads** — File uploads +- Requires `AllowUploads`, `ConsumeUploads`, `Upload` types (not in v2 server) +- Requires upload progress UI (not in v2 client) +- This is a large feature gap — entire upload subsystem missing from v2 + +### V2 Server-Side Feature Gaps for Self-Events + +The clock and chat examples need a way to send self-events from within handlers. In v1, `s.Self(ctx, event, data)` sends an event to the same socket. In v2: + +- `IslandInstance` has `CallSelf(ctx, event, data)` (`instance.go`) +- But handlers receive `(ctx, state, params)` — they don't have access to the instance or engine +- `engine.BroadcastToIsland(islandID, event)` could work but requires knowing the island ID +- The event loop goroutine could schedule self-events if the handler returns metadata + +This needs investigation — the clock example requires a recurring self-event pattern. + +## Code References + +- `examples/counter/main.go` — V2 counter example (277 lines), the pattern for all v2 examples +- `examples/counter/index.html` — V2 HTML template showing `` usage +- `examples/counter/counter.html` — V2 island render template +- `island.go` — Island definition with `HandleEvent`, `HandleSelf` +- `instance.go` — `IslandInstance` with `CallSelf`, `CallEvent` +- `engine.go:265-320` — `BroadcastToIslandType`, `BroadcastToIsland` +- `web/src/events.ts` — Client-side event wiring (all supported directives) +- `web/src/hooks.ts` — Hook system with `pushEvent`, `handleEvent` + +## Architecture Documentation + +### V2 Example Pattern + +Every v2 example follows this structure: + +``` +examples// +├── main.go # Island definition + HTTP server setup +├── index.html # Page template with elements +├── .html # Island render template (re-rendered on state change) +└── README.md # Optional documentation +``` + +The main.go file has three sections: +1. **Island definition** — state struct, constructor function, mount/render/event handlers +2. **Registration** — `live.RegisterIsland()` +3. **HTTP server** — engine setup, transport endpoints, page serving + +### Boilerplate Reduction Opportunity + +The counter example has ~100 lines of WebSocket/SSE transport boilerplate that is identical for every example. The event loop pattern (subscribe → mount, else → route) is the same. This could be extracted into a shared helper, but for examples clarity may be preferred over DRYness. + +## Related Research + +- `docs/research/2026-01-25-islands-component-architecture.md` — Islands architecture design +- `docs/research/2026-02-22-v2-branch-state-and-testing-gaps.md` — V2 branch state analysis + +## Resolved Questions + +1. **Params.Checkbox()** — Confirmed: exists in v2 at `params.go:21-31`. The todo example can use it as-is. + +2. **Transport boilerplate** — Decision: **keep self-contained**. Each example will have its own full transport setup for clarity. Users see the complete picture. + +3. **Self-event mechanism** — Decision: **return self-events from handlers**. Handlers will be able to return scheduled events alongside state. For delayed events (like clock tick), the returned event includes a `Delay` duration. The engine processes immediate events right away and schedules delayed events via `time.AfterFunc`. This keeps handlers pure and testable. + + **Design sketch:** + ```go + // New type for handler return values + type SelfEvent struct { + Event string + Data any + Delay time.Duration // 0 = immediate + } + + // Event handler returns state + optional self-events + // Current: func(ctx, state, params) (any, error) + // Proposed: func(ctx, state, params) (any, []SelfEvent, error) + // + // Or: keep existing signature, add a separate mechanism via context: + // live.ScheduleSelf(ctx, "tick", time.Now(), 1*time.Second) + ``` + + The clock example would use this to schedule a tick every second after mount. This requires a framework change to support — it is **not yet implemented**. + +4. **Which examples and merging** — Decision: **port all 7, merge where features overlap**. + +## Example Porting Plan + +### Merged Examples (5 examples from 7 master originals) + +| V2 Example | Merges From | Features Demonstrated | +|------------|-------------|----------------------| +| **clock** | clock | Server-pushed timed updates via `HandleSelf`, goroutine scheduling | +| **forms** | todo + prefill | Form validation (`live-change`), submission (`live-submit`), prefill, checkboxes, debounce, `live-value-*`, error display | +| **chat** | chat + cluster | Broadcasting (`BroadcastToIslandType`), `live-update="append"`, hooks (`live-hook`), form submission, clearing input after send | +| **hooks** | error | Error handling, `live-hook` with `handleEvent`/`pushEvent`, server→client event communication, `live-value-*` | +| **alpine** | alpine | Third-party JS framework integration (Alpine.js), autocomplete pattern, `live-submit`, `live-change`, `live-click`, `live-value-*` | + +**Rationale for merges:** +- **todo + prefill → forms**: Both demonstrate forms — todo shows list management + validation, prefill shows initial values + validation. Combining into one "forms" example shows the full form story (prefill, validate, submit, list, checkboxes) without redundancy. +- **chat + cluster → chat**: Cluster is just chat with PubSub. In v2, `BroadcastToIslandType` is built-in, so the "cluster" concept is already the default behavior. The chat example can show multiple island instances receiving broadcasts. +- **error → hooks**: The error example is really about hooks (`handleEvent` on the client to receive server errors). Renaming to "hooks" better describes the feature. + +### Not Portable Yet (3 examples) + +| Example | Missing V2 Feature | Notes | +|---------|-------------------|-------| +| **buttons** | `live-window-keyup` in client | Could add as stretch goal — window-level event listeners | +| **pagination** | `HandleParams`, `live-patch`, `PatchURL` | URL-driven state management not yet in v2 | +| **uploads** | Entire upload subsystem | Large feature gap | + +## Decisions + +### 1. Self-event Triggering: `SendSelf` + `WithEventDelay` + +**Decision: Option A — Mount handler sends initial self-event, `WithEventDelay` handles re-scheduling.** + +**`SendSelf` API:** +```go +// SendSelf schedules a self-event to be delivered to the current island instance. +// It uses the context to identify the target session and island. +// The event is delivered asynchronously after the current handler returns. +// +// Arguments: +// ctx context.Context - must contain session/island context (set by engine) +// event string - the self-event name (must match a HandleSelf registration) +// data any - arbitrary data passed to the self handler +func SendSelf(ctx context.Context, event string, data any) +``` + +The engine stores session ID and island ID in the context before calling handlers. `SendSelf` reads these from context and enqueues the event for delivery after the current handler completes. + +**`WithEventDelay` API:** +```go +// WithEventDelay configures automatic re-scheduling of a self-event after its +// handler completes. Each time the named self-handler finishes, the engine +// schedules another delivery of the same event after the specified delay. +// +// The delay event carries the return value of the previous handler invocation +// as its data (the current state). To customize the data, the handler can +// use SendSelf to schedule with specific data instead. +// +// Arguments: +// event string - the self-event name to re-schedule +// delay time.Duration - delay before re-delivery +// +// Returns an IslandOption for use with NewIsland. +func WithEventDelay(event string, delay time.Duration) IslandOption +``` + +**Complete clock example:** +```go +island, _ := live.NewIsland("clock", + live.WithMount(func(ctx context.Context, props live.Props, children string) (any, error) { + // Kick off the first tick immediately after mount + live.SendSelf(ctx, "tick", time.Now()) + return &ClockState{Time: time.Now()}, nil + }), + live.WithRender(renderHandler), + live.WithEventDelay("tick", 1*time.Second), // re-schedule after each tick handler +) + +island.HandleSelf("tick", func(ctx context.Context, state any, data any) (any, error) { + s := state.(*ClockState) + s.Time = time.Now() + return s, nil +}) +``` + +**Flow:** +1. Mount handler calls `SendSelf(ctx, "tick", time.Now())` → engine queues self-event +2. After mount completes, engine delivers the self-event to `HandleSelf("tick", ...)` +3. Handler updates state, returns +4. Engine sees `WithEventDelay("tick", 1s)` → schedules `time.AfterFunc(1s, deliverTick)` +5. After 1 second, engine delivers tick again → goto step 3 +6. On unmount, engine cancels any pending `time.AfterFunc` timers + +### 2. Error Handling: `WithErrorHandler` + Default + +**Decision: Option A with a default error handler.** + +```go +// WithErrorHandler configures a custom error handler for the island. +// When an event handler returns an error, this function is called to +// produce an event that is sent to the client. +// +// If not set, the default error handler sends: +// Event{T: "err", Data: {"err": error.Error()}} +func WithErrorHandler(fn func(ctx context.Context, err error) Event) IslandOption + +// Default behavior (when WithErrorHandler is not configured): +// The engine sends an error event to the client automatically. +// Client hooks can handle it via: this.handleEvent("err", (data) => { ... }) +``` + +**Usage in hooks example:** +```go +island, _ := live.NewIsland("hooks-demo", + live.WithMount(mountHandler), + live.WithRender(renderHandler), + // Custom error handler (optional — default sends {"t":"err","d":{"err":"..."}}) + live.WithErrorHandler(func(ctx context.Context, err error) Event { + data, _ := json.Marshal(map[string]string{"err": err.Error()}) + return Event{T: "err", Data: data} + }), +) +``` + +**Default handler** — When `WithErrorHandler` is not set, the engine uses a built-in default that sends `Event{T: "err", Island: instanceID, Data: {"err": error.Error()}}` to the client. This means error events work out of the box without configuration. + +## Open Questions + +None — all questions resolved. Ready for implementation planning. diff --git a/docs/research/2026-03-01-diffing-algorithms-sota-comparison.md b/docs/research/2026-03-01-diffing-algorithms-sota-comparison.md new file mode 100644 index 0000000..464df20 --- /dev/null +++ b/docs/research/2026-03-01-diffing-algorithms-sota-comparison.md @@ -0,0 +1,728 @@ +--- +date: 2026-03-01T12:00:00Z +researcher: josh +topic: "DOM Diffing Algorithm Analysis: Current Implementation vs State of the Art" +tags: [research, diffing, dom, rendering, performance, algorithms] +last_updated: 2026-03-01 +last_updated_by: josh +--- + +# Research: DOM Diffing Algorithm Analysis — Current Implementation vs State of the Art + +## Research Question + +Is the current approach to diffing the rendered output optimal? How does the framework track each element in the DOM? Could this be done differently? What other approaches exist in the wild? Compare the SOTA for diffing with pros and cons of each approach. + +## Summary + +Live v2 uses a **position-based, server-side tree diff** algorithm. On each render, the server parses the full HTML into `golang.org/x/net/html` node trees, assigns hierarchical anchor attributes based on tree position, compares old vs new trees node-by-node at each position, and sends JSON patches over WebSocket. The client locates target elements via anchor attribute selectors and applies Replace/Append/Prepend operations using native DOM APIs. + +This approach is most closely related to **Phoenix LiveView's** server-side model, but with a key architectural difference: LiveView diffs at the template data level (only sending changed dynamic values), while Live v2 diffs at the rendered HTML tree level (full tree comparison on every render). Among client-side approaches, the position-based matching is simpler than morphdom/idiomorph (which use ID-based matching) and lacks the key-based list optimization found in React, Vue, and Snabbdom. + +## Detailed Findings + +### 1. Live v2's Current Diffing Implementation + +#### Server-Side Diff Engine (`diff.go`) + +The core algorithm in `diff.go` (~619 lines) operates in four phases: + +**Phase 1 — Tree Anchoring** (`diff.go:315-327`): +- Depth-first traversal assigns hierarchical IDs to each relevant node +- Format: `_l_0_1_0_2` (page-level) or `_i__0_1_0_2` (island-scoped) +- Anchors are stored as HTML attribute keys (not values) — e.g. `
` +- The `anchorGenerator` struct (`diff.go:40-47`) tracks position via an `idx []int` slice +- `inc()` increments the last index (next sibling), `level()` appends a new depth level (first child) + +**Phase 2 — Node Equality** (`diff.go:552-576`): +- `nodeEqual()` checks: node type, attribute count + deep attribute equality, trimmed text data +- Insignificant whitespace (whitespace-only text nodes) is filtered out by `nodeRelevant()` (`diff.go:540-549`) +- `shapeTree()` (`diff.go:329-353`) removes non-relevant nodes before comparison + +**Phase 3 — Recursive Comparison** (`diff.go:377-433`): +- `compareNodes()` walks both trees simultaneously at each child position +- If both nil: no change. If old nil: append new. If new nil: replace with empty (delete). If not equal: replace +- Children are compared index-by-index: `for i := 0; i < max(len(oldChildren), len(newChildren)); i++` +- `live-update` modifier attributes (`diff.go:500-538`) override the default replace action with append/prepend/ignore + +**Phase 4 — Patch Generation** (`diff.go:435-464`): +- Each difference produces a `Patch` struct: `{Anchor, Action, HTML, IslandID}` +- Actions: `Noop(0)`, `Replace(1)`, `Append(2)`, `Prepend(3)` +- The entire target node is rendered to an HTML string for the patch — no attribute-only patches exist + +#### Render Pipeline (`engine.go:342-373`) + +``` +Event received → handler updates state → RenderIsland() produces new HTML +→ previousHTML retrieved from instance.lastRenderedHTML → DiffIsland() computes patches +→ patches marshalled to JSON → sent as EventPatch over WebSocket +``` + +The previous render is stored as `template.HTML` on `IslandInstance.lastRenderedHTML` (`instance.go:38-39`), updated after each render (`instance.go:144-147`). + +#### Client-Side Patch Application (`web/src/island.ts:237-293`) + +The client applies patches using native DOM APIs: +1. Find target: `this.querySelector(*[${patch.Anchor}])` — scoped to the island element +2. Parse HTML: `html2Node()` uses a `