Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ packages, and tooling contracts may change before a stable release.

### Implemented

- Page stores can opt into browser persistence with a `persist "local"` or
`persist "session"` modifier
(`store cart ui.CartState = ui.NewCartState() persist "local"`). The generated
store runtime hydrates from localStorage/sessionStorage on load, re-hydrates on
SPA navigation (stores first declared on a later route are picked up on content
swap, and a store first declared without persistence adopts a later route's
persist config and restores its saved value, so persistence never depends on
navigation order), writes the store's declared fields on change, mirrors cross-tab writes
for `persist "local"` stores through the `storage` event (`persist "session"`
stores are tab-local), exposes `window.__gowdkStores.clear(name)` to drop
a persisted store, and discards persisted state whose embedded schema hash no longer
matches the store's shape (so a struct change never restores stale data).
Only the store's own fields persist — never component state, props, or computed
values. New diagnostics: `page_store_persist_scope_invalid` (error),
`page_store_persist_secret_field` (warning, raised for nested secret-resembling
fields such as `Profile.Token`, not only top-level fields),
`page_store_persist_key_conflict` (warning), and
`page_store_persist_scope_conflict` (warning, when the same store name is
persisted under different `local`/`session` scopes across pages and would
otherwise let navigation order decide the backend). Persistence is a JS-island/store
runtime feature; WASM islands do not yet participate in page stores.
- M4 Go interop is complete for the current 0.x surface: a user can see why a Go
function or type did or did not bind. `gowdk inspect go-bindings` emits a
versioned JSON report (schema version 1) covering actions, APIs, fragments,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ This table describes the current demoable 0.x slice. Status levels:
| Fragments | Works, contract unstable | Partial form submissions and standalone fragment routes can return server fragments and remount local islands. | Richer fragment rendering and broader local client behavior are still hardening work. | [Partials](docs/language/partials.md) | [Fragments](examples/partials/patients-fragment.page.gwdk) |
| SSR | Works, contract unstable | Pages with `load {}` or `go ssr {}` can build request-time handlers when the SSR addon is enabled. | Typed route-param accessors, lifecycle docs, and error/cache contracts need more hardening. | [SSR](docs/language/ssr.md) | [SSR](examples/ssr/simple-ssr.page.gwdk) |
| Hybrid | Early | Hybrid request-time route metadata and generated request-time pages exist for the supported slice. | The public hybrid source contract, streaming, and data refresh policy are not stable. | [Hybrid](docs/language/hybrid.md) | [Hybrid](examples/ssr/hybrid-static.page.gwdk) |
| Components | Works, contract unstable | Components support imported contracts, slots, scoped CSS/assets, first local client behavior, and generated island assets. | Non-string props, richer slots/events, real `g:if`/`g:for`, lifecycle cleanup, and dependency diagnostics are planned. | [Components](docs/language/components.md) | [Components](examples/components/base/base-components.page.gwdk) |
| Components | Works, contract unstable | Components support imported contracts, slots, scoped CSS/assets, first local client behavior, and generated island assets. Page stores can opt into localStorage/sessionStorage persistence with `persist "local"`/`persist "session"`. | Non-string props, richer slots/events, real `g:if`/`g:for`, lifecycle cleanup, dependency diagnostics, and store persistence for WASM islands are planned. | [Components](docs/language/components.md) | [Components](examples/components/base/base-components.page.gwdk) |
| WASM islands | Early | Component-level `wasm` and page-level `go client {}` can emit Go `js/wasm` browser assets for supported fixtures. | ABI docs, size reporting, runtime validation, and browser behavior coverage need hardening. | [Components](docs/language/components.md) | [Test fixture](testfixture/islands/islands.go) |
| CSS/assets | Works, contract unstable | CSS processors, page CSS, scoped component CSS, component assets, asset manifests, content-hashed filenames, and optional Tailwind wrapper exist. | CSS processor contracts and optional dependency boundaries need hardening. | [CSS](docs/reference/css.md) | [CSS](examples/css/styled.page.gwdk) |
| One-binary output | Works, contract unstable | `gowdk build --app --bin` can generate and compile an embedded Go server for supported SPA/backend/SSR slices. | Runtime operations, split/backend-only deploys, and artifact smoke coverage are still expanding. | [Deployment](docs/reference/deployment.md) | [Embed](examples/embed/site.page.gwdk) |
Expand Down
52 changes: 51 additions & 1 deletion docs/language/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,57 @@ Store use is explicit. Same-page stores use `client { use cart }`; stores from
another discovered `.gwdk` package require a GOWDK `use` alias and a qualified
client store reference such as `client { use stores.cart }`. Cross-package
stores are validated by alias and store name, not discovered globally.
App-global stores and cross-route persistence are deferred.
App-global stores are deferred.

A page store can opt into browser persistence with a `persist` modifier:

```gwdk
store cart ui.CartState = ui.NewCartState() persist "local"
store prefs ui.UIPrefs = ui.DefaultPrefs() persist "session"
```

`persist "local"` keeps the store in `localStorage` (survives a browser
restart); `persist "session"` keeps it in `sessionStorage` (survives reload and
SPA navigation, cleared when the tab closes). Persistence is keyed by store name
(`gowdk:store:<name>`), so the same persisted store keeps its value across routes
on the origin. Only the store's own declared fields are persisted — never
component state, props, or computed values. The compiler embeds a hash of the
store's struct shape; when the shape changes, stale persisted state is discarded
rather than restored, so a struct change never crashes on old data. Because
browser storage is readable by any script on the origin, persisting a field whose
name resembles a secret (`token`, `password`, `secret`, `auth`, …) is a warning —
including a nested field such as `Profile.Token`, because persistence writes the
whole value of each top-level field: keep credentials and trusted authorization
state server-side. An unknown scope is rejected — see
`gowdk explain page_store_persist_scope_invalid`.

`persist "local"` stores also sync across tabs: when one tab writes, other tabs
on the origin mirror the value through the browser `storage` event. `persist
"session"` stores are deliberately tab-local — `sessionStorage` is partitioned
per top-level tab, so session-scoped stores do not (and cannot) sync across tabs.
To drop a persisted
store (for example after checkout or logout), call
`window.__gowdkStores.clear("<name>")`, which removes the stored copy and resets
the store to its build-time init value. If two pages persist a store with the
same name but different shapes, they share one storage key and discard each
other's data on navigation; the compiler warns with
`page_store_persist_key_conflict`. If they share the same shape but declare
different `local`/`session` scopes, the runtime keeps whichever scope initialized
first and the compiler warns with `page_store_persist_scope_conflict`. A store
first reached on a route that does not persist it still adopts persistence when a
later route declares it, restoring the saved value regardless of navigation order.

Persistence survives SPA navigation: when the client runtime swaps page content
it re-scans store seeds, so a store first declared on a later client-side route
hydrates without a full page load, and a store already in memory keeps its value.

Current limits. Persistence is a JS-island/store-runtime feature: WASM islands
do not yet participate in page stores, so a WASM-only island will not read or
write a persisted store. Because a component references store fields through its
own `state` declaration (`use <name>` syncs that field with the store), a
component that reads a persisted store still declares a matching `state` shape.
Invalid scopes are reported but not auto-fixed, because choosing `local` vs
`session` is a deliberate decision.

Exports are typed component metadata today. They document values a component
intends to expose, but parent pages/components do not yet have a stable runtime
Expand Down
14 changes: 14 additions & 0 deletions docs/language/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,20 @@ or trusted authorization, validation, database, or action state. Runtime
cross-island subscriptions are planned; the current contract validates
declarations and explicit uses, but does not make stores global app state.

A page store may opt into browser persistence with a trailing `persist` scope:

```gwdk
store cart ui.CartState = ui.NewCartState() persist "local"
```

The scope must be `"local"` (localStorage) or `"session"` (sessionStorage); any
other value is rejected with `page_store_persist_scope_invalid`. Persisted store
state is keyed by store name, restores over the build-time init value on load,
is discarded when the store's struct shape changes, and warns when a persisted
field name resembles a secret (nested fields included). Declaring the same store
name with a different `local`/`session` scope across pages warns with
`page_store_persist_scope_conflict`.

Client blocks can declare limited DOM refs for safe methods:

```gwdk
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/diagnostic-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ Parser diagnostics emit stable codes for common unsupported syntax and keep
`redundant_component_implementation`, `component_contract_error`,
`component_field_error`, `component_client_error`,
`duplicate_component_emit`, `duplicate_page_store`, `page_store_error`,
`page_store_persist_key_conflict`, `page_store_persist_scope_conflict`,
`page_store_persist_scope_invalid`, `page_store_persist_secret_field`,
`unknown_component_store`, `view_parse_error`.
- Accessibility: `missing_img_alt`.
- Go blocks and generated app wiring: `invalid_go_block`,
Expand Down
55 changes: 55 additions & 0 deletions examples/store-persist/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Persisted page store

A page `store` that opts into browser persistence with `persist "local"`, so a
shared cart survives reloads and SPA navigation.

```gwdk
store cart ui.CartState = ui.NewCartState() persist "local"
```

- `ui/cart.go` — the user-owned Go state. GOWDK serializes only its declared
fields; it adds no opinions to the struct.
- `shop.page.gwdk` — declares the persisted store and renders the two islands.
- `add-button.cmp.gwdk` / `cart-badge.cmp.gwdk` — share the store via
`client { use cart }`.

## Build it

```sh
gowdk build --out ./dist \
examples/store-persist/shop.page.gwdk \
examples/store-persist/add-button.cmp.gwdk \
examples/store-persist/cart-badge.cmp.gwdk
```

The generated `shop/index.html` carries the persist config on the store seed:

```html
<script type="application/json" data-gowdk-store="cart"
data-gowdk-persist="local"
data-gowdk-persist-key="gowdk:store:cart"
data-gowdk-persist-version="…">{"Count":0}</script>
```

and `assets/gowdk/islands/stores.js` hydrates the store from `localStorage` on
load and writes it back on every change.

## What to try

1. Open `/shop`, click **Add to cart** a few times — the badge counts up.
2. Reload the page. The badge keeps your count (restored from `localStorage`).
3. Change `CartState`'s shape in `ui/cart.go` and rebuild. The old persisted
value is discarded automatically (the embedded schema hash changes), so the
store falls back to its init value instead of restoring stale data.

## Scopes

- `persist "local"` — `localStorage`; survives a browser restart, shared across
all tabs and routes on the origin.
- `persist "session"` — `sessionStorage`; survives reload and SPA navigation,
cleared when the tab closes.

Persisted store state is browser-visible: never persist secrets, session tokens,
or trusted authorization state. The compiler warns
(`page_store_persist_secret_field`) when a persisted field name resembles a
secret.
21 changes: 21 additions & 0 deletions examples/store-persist/add-button.cmp.gwdk
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package shop

import ui "github.com/cssbruno/gowdk/examples/store-persist/ui"

component AddButton

// The local state shape matches the cart store; `use cart` syncs Count across
// every island that uses the store, and the page's persist "local" keeps it.
state ui.CartState = ui.NewCartState()

client {
use cart

func Add() {
Count++
}
}

view {
<button g:on:click={Add()}>Add to cart</button>
}
22 changes: 22 additions & 0 deletions examples/store-persist/cart-badge.cmp.gwdk
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package shop

import ui "github.com/cssbruno/gowdk/examples/store-persist/ui"

component CartBadge

state ui.CartState = ui.NewCartState()

client {
use cart

computed Label string {
if Count == 0 {
return "Cart empty"
}
return string(Count)
}
}

view {
<span class:has-items={Count > 0}>{Label}</span>
}
20 changes: 20 additions & 0 deletions examples/store-persist/shop.page.gwdk
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package shop

import ui "github.com/cssbruno/gowdk/examples/store-persist/ui"

page shop
route "/shop"
guard public

// persist "local" keeps the cart in localStorage. Add to cart, reload the page,
// and the badge still shows your count. The same store is shared across routes
// on this origin and discarded automatically if CartState's shape changes.
store cart ui.CartState = ui.NewCartState() persist "local"

view {
<main>
<h1>Shop</h1>
<CartBadge />
<AddButton />
</main>
}
16 changes: 16 additions & 0 deletions examples/store-persist/ui/cart.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Package ui holds the user-owned Go state for the store-persistence example.
// GOWDK never touches this struct; it only serializes its declared fields.
package ui

// CartState is the cart store's Go type. When the page store opts into
// persist "local", only these declared fields are written to browser storage,
// keyed by their exported Go field name (so the seed and persisted blob use
// "Count", not a json tag).
type CartState struct {
Count int
}

// NewCartState is the build-time initializer for the cart store.
func NewCartState() CartState {
return CartState{Count: 0}
}
2 changes: 1 addition & 1 deletion internal/buildgen/islands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ fn Add() {
html := readFile(t, filepath.Join(outputDir, "counter", "index.html"))
for _, expected := range []string{
`<script type="application/json" data-gowdk-store="cart">{"Count":1,"Open":false}</script>`,
`<script src="/assets/gowdk/islands/stores.js" defer></script>`,
`<script src="/assets/gowdk/islands/stores.js" data-gowdk-store-runtime defer></script>`,
`<script src="/assets/gowdk/islands/Counter.js" defer></script>`,
`data-gowdk-client="{&#34;handlers&#34;:{&#34;Add&#34;:{&#34;statements&#34;:[&#34;Count++&#34;]}},&#34;stores&#34;:[&#34;cart&#34;]}"`,
} {
Expand Down
84 changes: 79 additions & 5 deletions internal/buildgen/render.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package buildgen

import (
"encoding/json"
"fmt"
"hash/fnv"
"net/url"
"sort"
"strconv"
"strings"

"github.com/cssbruno/gowdk"
Expand Down Expand Up @@ -284,8 +288,18 @@ func isInternalNavigationHref(value string) bool {
}

type pageStoreSeed struct {
Name string
JSON string
Name string
JSON string
Persist *storePersistSeed
}

// storePersistSeed is the persistence config carried to the browser store
// registry on the seed <script> tag. Version is a hash of the store's resolved
// struct shape so stale browser storage from an older shape is discarded.
type storePersistSeed struct {
Scope string
Key string
Version string
}

func pageStoreSeeds(page gwdkir.Page) ([]pageStoreSeed, error) {
Expand All @@ -302,11 +316,58 @@ func pageStoreSeeds(page gwdkir.Page) ([]pageStoreSeed, error) {
if err != nil {
return nil, fmt.Errorf("store %s init: %w", store.Name, err)
}
seeds = append(seeds, pageStoreSeed{Name: store.Name, JSON: string(payload)})
seed := pageStoreSeed{Name: store.Name, JSON: string(payload)}
if store.Persist == "local" || store.Persist == "session" {
resolved, err := gotypes.ResolveStruct(page.Imports, store.Type)
if err != nil {
return nil, fmt.Errorf("store %s persist: %w", store.Name, err)
}
seed.Persist = &storePersistSeed{
Scope: store.Persist,
Key: "gowdk:store:" + store.Name,
Version: storeSchemaHash(resolved, seed.JSON),
}
}
seeds = append(seeds, seed)
}
return seeds, nil
}

// storeSchemaHash derives a stable short token from a store's shape. It folds in
// both the resolved Go field names and fully-qualified types (catching field
// add/remove/retype, including nested fields) and the on-wire JSON keys of the
// seed (catching json-tag-only renames that leave the Go field unchanged). The
// token changes whenever the shape changes, which the browser runtime uses to
// discard persisted state written against an older shape.
func storeSchemaHash(resolved gotypes.Struct, seedJSON string) string {
digest := fnv.New32a()
typeKeys := make([]string, 0, len(resolved.FieldTypes))
for name := range resolved.FieldTypes {
typeKeys = append(typeKeys, name)
}
sort.Strings(typeKeys)
for _, name := range typeKeys {
digest.Write([]byte(name))
digest.Write([]byte{0})
digest.Write([]byte(resolved.FieldTypes[name]))
digest.Write([]byte{0})
}
var wire map[string]json.RawMessage
if err := json.Unmarshal([]byte(seedJSON), &wire); err == nil {
wireKeys := make([]string, 0, len(wire))
for key := range wire {
wireKeys = append(wireKeys, key)
}
sort.Strings(wireKeys)
digest.Write([]byte{1})
for _, key := range wireKeys {
digest.Write([]byte(key))
digest.Write([]byte{0})
}
}
return strconv.FormatUint(uint64(digest.Sum32()), 16)
}

func document(config gowdk.Config, page gwdkir.Page, body string, stylesheets []gowdk.Stylesheet, storeSeeds []pageStoreSeed, scripts []gowdk.Script) string {
title := page.ID
if page.Metadata.Title != "" {
Expand Down Expand Up @@ -370,14 +431,27 @@ func document(config gowdk.Config, page gwdkir.Page, body string, stylesheets []
if strings.TrimSpace(seed.Name) == "" {
continue
}
head = append(head, " <script type=\"application/json\""+gowhtml.Attr("data-gowdk-store", seed.Name)+">"+escapeScriptJSON(seed.JSON)+"</script>")
attrs := gowhtml.Attr("data-gowdk-store", seed.Name)
if seed.Persist != nil {
attrs += gowhtml.Attr("data-gowdk-persist", seed.Persist.Scope)
attrs += gowhtml.Attr("data-gowdk-persist-key", seed.Persist.Key)
attrs += gowhtml.Attr("data-gowdk-persist-version", seed.Persist.Version)
}
head = append(head, " <script type=\"application/json\""+attrs+">"+escapeScriptJSON(seed.JSON)+"</script>")
}
for _, script := range nonEmptyScripts(scripts) {
tag := " <script"
if strings.TrimSpace(script.Type) != "" {
tag += gowhtml.Attr("type", script.Type)
}
tag += gowhtml.Attr("src", script.Src) + " defer></script>"
tag += gowhtml.Attr("src", script.Src)
// Mark the store runtime so the SPA navigation runtime can run (and
// hydrate) it before island bundles, which auto-mount on execution and read
// the store registry during mount.
if script.Src == storeRuntimeHref {
tag += " data-gowdk-store-runtime"
}
tag += " defer></script>"
head = append(head, tag)
}
head = append(head, "</head>")
Expand Down
Loading