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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

## v3.5.0 - 2026-06-24

### Added

- **Hooks now run during server rendering (`ui.RenderToString`).** Previously the
string serializer was hook-less: a component calling any hook errored with
"called outside component context", so only hook-free trees could be
server-rendered. `RenderToString` now installs a transient hook fiber per
component, so on the server:
- `GoUseState` returns its initial value (the setter is a no-op);
- `GoUseRef` and `GoUseMemo` compute normally;
- `GoUseContextValue` resolves to the nearest provider's value — context flows
down through host elements and nested providers override correctly;
- `GoUseEffect` is queued but never committed, so effects do not run on the
server (matching React).

This makes GWC able to server-render real (hook-using) components, matching
React's `ReactDOMServerIntegrationHooks`. Implemented by threading the inherited
context map through the SSR renderer and deriving it at each `ContextProvider`
boundary. Covered by `ui/ssr_hooks_test.go`.

Notes: SSR-with-hooks uses the package-global current-fiber like the client
reconciler, so a single process renders hook components one tree at a time
(concurrent goroutine SSR of hook components needs external serialization — a
pre-existing hook-architecture constraint). The streaming SSR path runs hooks
but does not yet thread context (a documented follow-up); the buffered
`RenderToString` path is fully supported.

## v3.4.10 - 2026-06-24

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion docs/REACT_TEST_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ re-renders/effects are observable inline. SSR behavior is tested via
| ReactDOMServerIntegrationBasic | basic SSR render, nested components, nil | ✅ | ui/ssr_components_test.go |
| ReactDOMServerIntegrationFragment | fragment SSR | ✅ | ui/ssr_components_test.go (component->fragment hoist) |
| ReactDOMServerIntegrationSpecialTypes | numbers/bools/null children | 🚫 | Go static typing (ui.Node children) |
| ReactDOMServerIntegrationHooks | hooks under SSR | 🔶 | ui/ssr_hooks_boundary_test.go — ARCHITECTURAL GAP: ui.RenderToString is hook-less; hook/context components render only via the reconciler (RenderInto), which IS tested. String-SSR of hook components errors. Candidate for a dedicated "SSR hooks" feature (transient fiber tree + context propagation + hook server-mode). |
| ReactDOMServerIntegrationHooks | hooks under SSR | | ui/ssr_hooks_test.go — IMPLEMENTED in v3.5.0: RenderToString runs useState/useRef/useMemo/useContext (effects skipped); context flows + nested providers override. Streaming path: hooks yes, context threading is a follow-up. |
| ReactDOMServerIntegrationRefs | refs inert during SSR | ✅ | ui/ssr_ref_and_context_test.go |
| ReactDOMServerIntegrationNewContext | context under SSR / multi-consumer | ✅ | ui/ssr_ref_and_context_test.go, ui/context_native_test.go |
| ReactDOMServerIntegrationTextarea/Input/Checkbox | controlled inputs SSR | ✅ | ui/ssr_controlled_inputs_test.go (textarea value->content: found+fixed v3.4.9) |
Expand Down
25 changes: 13 additions & 12 deletions examples/public-examples-site/assets/docs/latest-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@
This docs-site page mirrors the first section returned by
`tools/changelogcheck.LatestEntry(CHANGELOG.md)`.

## v3.4.10 - 2026-06-24
## v3.5.0 - 2026-06-24

The current latest changelog section is `v3.4.10 - 2026-06-24`. **Fixed:** a
controlled `<select value="…">` did not mark the selected option in SSR — the
serializer put a browser-ignored `value` attribute on the `<select>`, so a
server-rendered select showed no initial selection and mismatched on hydration.
The serializer now drops `value` from the `<select>` and renders the matching
`<option>` with `selected` (matching by option value, or by text when it has no
value, and recursing into `<optgroup>`), in both the buffered and streaming
renderers — matching React's `ReactDOMServerIntegrationSelect`. Completes the
controlled-input SSR work begun in v3.4.9 (textarea). It is checked by the
blocking `tools/changelogcheck` release gate
before a versioned release can ship.
The current latest changelog section is `v3.5.0 - 2026-06-24`. **Added:** hooks
now run during server rendering (`ui.RenderToString`). Previously the string
serializer was hook-less — a component calling any hook errored, so only
hook-free trees could be server-rendered. `RenderToString` now installs a
transient hook fiber per component: `GoUseState` returns its initial value,
`GoUseRef`/`GoUseMemo` compute, `GoUseContextValue` resolves to the nearest
provider value (context flows through host elements; nested providers override),
and `GoUseEffect` is queued but never run on the server — matching React's
`ReactDOMServerIntegrationHooks`. This lets GWC server-render real hook-using
components. The buffered path is fully supported; the streaming path runs hooks
but does not yet thread context (a documented follow-up). It is checked by the
blocking `tools/changelogcheck` release gate before a versioned release can ship.

See the repository root `CHANGELOG.md` for the full entry body.
8 changes: 4 additions & 4 deletions internal/runtime/helper_branches_additional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ func TestRuntimeHotReloadHelperBranches(parseT *testing.T) {

// TestRuntimeSSRHelperBranches covers error-boundary SSR fallbacks, prop conversion, and typed memo restoration.
func TestRuntimeSSRHelperBranches(parseT *testing.T) {
if parseErr := renderErrorBoundaryToString(&strings.Builder{}, nil); parseErr != nil {
if parseErr := renderErrorBoundaryToString(&strings.Builder{}, nil, nil); parseErr != nil {
parseT.Fatalf("expected nil boundary render to succeed, got %v", parseErr)
}

Expand All @@ -250,7 +250,7 @@ func TestRuntimeSSRHelperBranches(parseT *testing.T) {
}, nil),
},
}
if parseErr := renderErrorBoundaryToString(parseBoundaryBuilder, parseBoundaryElement); parseErr != nil {
if parseErr := renderErrorBoundaryToString(parseBoundaryBuilder, parseBoundaryElement, nil); parseErr != nil {
parseT.Fatalf("expected error-boundary fallback render to recover, got %v", parseErr)
}
if parseBoundaryError != "boom" {
Expand All @@ -277,7 +277,7 @@ func TestRuntimeSSRHelperBranches(parseT *testing.T) {
}, nil),
},
}
if parseErr := renderErrorBoundaryToString(parseElementFallbackBuilder, parseElementFallbackBoundary); parseErr != nil {
if parseErr := renderErrorBoundaryToString(parseElementFallbackBuilder, parseElementFallbackBoundary, nil); parseErr != nil {
parseT.Fatalf("expected fallback element render to recover, got %v", parseErr)
}
if !parseElementFallbackCalled {
Expand All @@ -302,7 +302,7 @@ func TestRuntimeSSRHelperBranches(parseT *testing.T) {
}, nil),
},
}
if parseErr := renderErrorBoundaryToString(parseNoFallbackBuilder, parseNoFallbackBoundary); parseErr != nil {
if parseErr := renderErrorBoundaryToString(parseNoFallbackBuilder, parseNoFallbackBoundary, nil); parseErr != nil {
parseT.Fatalf("expected missing fallback branch to swallow panic, got %v", parseErr)
}
if !isParseOnErrorCalled {
Expand Down
16 changes: 8 additions & 8 deletions internal/runtime/hydration_helper_gap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ func TestSSRAndHotReloadHelperGapBranches(parseT *testing.T) {
Type: NewContextProviderType(NewContextDescriptor("default")),
Children: []any{CreateElement("span", nil, "provider")},
}
if parseErr := renderElementToString(parseBuilder, parseProviderElement); parseErr != nil {
if parseErr := renderElementToString(parseBuilder, parseProviderElement, nil); parseErr != nil {
parseT.Fatalf("renderElementToString(context provider): %v", parseErr)
}
if parseBuilder.String() != "<span>provider</span>" {
Expand All @@ -311,7 +311,7 @@ func TestSSRAndHotReloadHelperGapBranches(parseT *testing.T) {
Type: PortalNodeType,
Children: []any{CreateElement("span", nil, "portal")},
}
if parseErr := renderElementToString(parseBuilder, parsePortalElement); parseErr != nil {
if parseErr := renderElementToString(parseBuilder, parsePortalElement, nil); parseErr != nil {
parseT.Fatalf("renderElementToString(portal): %v", parseErr)
}
if parseBuilder.String() != "<span>portal</span>" {
Expand All @@ -320,7 +320,7 @@ func TestSSRAndHotReloadHelperGapBranches(parseT *testing.T) {

parseBuilder.Reset()
parseReactiveTextFallback := &Element{Type: ReactiveTextNodeType, TextContent: "fallback"}
if parseErr := renderElementToString(parseBuilder, parseReactiveTextFallback); parseErr != nil {
if parseErr := renderElementToString(parseBuilder, parseReactiveTextFallback, nil); parseErr != nil {
parseT.Fatalf("renderElementToString(reactive text fallback): %v", parseErr)
}
if parseBuilder.String() != "fallback" {
Expand All @@ -332,30 +332,30 @@ func TestSSRAndHotReloadHelperGapBranches(parseT *testing.T) {
Type: ReactiveRegionNodeType,
Props: map[string]any{reactiveRegionRenderProp: func() *Element { return CreateElement("em", nil, "region") }},
}
if parseErr := renderElementToString(parseBuilder, parseReactiveRegionElement); parseErr != nil {
if parseErr := renderElementToString(parseBuilder, parseReactiveRegionElement, nil); parseErr != nil {
parseT.Fatalf("renderElementToString(reactive region): %v", parseErr)
}
if parseBuilder.String() != "<em>region</em>" {
parseT.Fatalf("unexpected reactive region markup: %q", parseBuilder.String())
}

parseBuilder.Reset()
if parseErr := renderChildrenToString(parseBuilder, []any{nil, "text", 7}); parseErr != nil {
if parseErr := renderChildrenToString(parseBuilder, []any{nil, "text", 7}, nil); parseErr != nil {
parseT.Fatalf("renderChildrenToString(): %v", parseErr)
}
if parseBuilder.String() != "text7" {
parseT.Fatalf("unexpected child markup: %q", parseBuilder.String())
}

parseUnsupportedReturn := func() string { return "bad" }
if _, parseErr := resolveComponentElement(&Element{Type: parseUnsupportedReturn}); parseErr == nil || !strings.Contains(parseErr.Error(), "must return *runtime.Element") {
if _, parseErr := resolveComponentElement(&Element{Type: parseUnsupportedReturn}, nil); parseErr == nil || !strings.Contains(parseErr.Error(), "must return *runtime.Element") {
parseT.Fatalf("expected unsupported return type error, got %v", parseErr)
}
parseUnsupportedArity := func(parseLeft string, parseRight string) *Element { return nil }
if _, parseErr := resolveComponentElement(&Element{Type: parseUnsupportedArity}); parseErr == nil || !strings.Contains(parseErr.Error(), "unsupported arity") {
if _, parseErr := resolveComponentElement(&Element{Type: parseUnsupportedArity}, nil); parseErr == nil || !strings.Contains(parseErr.Error(), "unsupported arity") {
parseT.Fatalf("expected unsupported arity error, got %v", parseErr)
}
if _, parseErr := resolveComponentElement(&Element{Type: 42}); parseErr == nil || !strings.Contains(parseErr.Error(), "unsupported element type") {
if _, parseErr := resolveComponentElement(&Element{Type: 42}, nil); parseErr == nil || !strings.Contains(parseErr.Error(), "unsupported element type") {
parseT.Fatalf("expected unsupported element type error, got %v", parseErr)
}

Expand Down
Loading