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

## v3.5.1 - 2026-06-24

### Added

- **Streaming SSR (`RenderToStream`) now threads context to hooks.** v3.5.0 ran
hooks in the streaming path but passed no context, so a streamed component's
`GoUseContextValue` fell back to the descriptor default. The streaming state now
carries the inherited context map (derived at each `ContextProvider` boundary and
restored after its children, and captured per pending async boundary so deferred
Suspense content resolves the same context), so streamed `useContext` resolves
to the nearest provider — including nested-provider override. Completes the SSR
hooks work begun in v3.5.0. Covered by `TestStreamRendersHooksAndContext`.

## v3.5.0 - 2026-06-24

### Added
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 @@ -36,7 +36,7 @@ re-renders/effects are observable inline. SSR behavior is tested via
| ReactDOMServerIntegrationSelect | select value -> selected option | ✅ | ui/ssr_select_test.go (found+fixed v3.4.10; match by value/text, optgroup) |
| ReactDOMServerIntegrationRefs | refs under SSR | ⬜ | |
| ReactDOMServerIntegrationNewContext | context under SSR | ⬜ | |
| ReactDOMFizzServer* | streaming SSR | 🔶 | partial (internal/runtime/ssr_stream) |
| ReactDOMFizzServer* | streaming SSR + hooks/context | 🔶 | internal/runtime/ssr_stream_hooks_test.go — streaming runs hooks + threads context (v3.5.1); async-boundary/Suspense streaming itself still partial |
| ReactDOMServer*Hydration / SelectiveHydration | hydration | 🔶 | partial (existing hydration tests) |

## react (core)
Expand Down
24 changes: 11 additions & 13 deletions examples/public-examples-site/assets/docs/latest-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,17 @@
This docs-site page mirrors the first section returned by
`tools/changelogcheck.LatestEntry(CHANGELOG.md)`.

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

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.
The current latest changelog section is `v3.5.1 - 2026-06-24`. **Added:**
streaming SSR (`RenderToStream`) now threads context to hooks. v3.5.0 ran hooks
in the streaming path but passed no context, so a streamed component's
`GoUseContextValue` fell back to the descriptor default. The streaming state now
carries the inherited context map (derived at each `ContextProvider` boundary and
restored after its children, plus captured per pending async boundary so deferred
Suspense content resolves the same context), so streamed `useContext` resolves to
the nearest provider — including nested-provider override. Completes the SSR hooks
work begun in v3.5.0. 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.
34 changes: 24 additions & 10 deletions internal/runtime/ssr_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,21 @@ type SSRStreamOptions struct {
}

type ssrStreamPendingBoundary struct {
id string
content *Element
suspension *Suspension
id string
content *Element
suspension *Suspension
contextValues map[int64]any
}

type ssrStreamState struct {
nextBoundaryID int
pending []ssrStreamPendingBoundary
options SSRStreamOptions
// contextValues carries the inherited context (descriptor ID -> value) for the
// subtree currently being rendered, so a streamed component's GoUseContextValue
// resolves to the nearest provider. It is derived at each ContextProvider
// boundary and restored when that boundary's children finish.
contextValues map[int64]any
}

// RenderToStream writes an SSR shell immediately and then flushes async
Expand Down Expand Up @@ -149,7 +155,7 @@ func renderSSRStreamBoundaryChunk(parseCtx context.Context, parseBoundary ssrStr
panic(parseRecovered)
}
}()
return renderElementToString(&parseContent, parseBoundary.content, nil)
return renderElementToString(&parseContent, parseBoundary.content, parseBoundary.contextValues)
}()
if parseErr != nil {
return SSRStreamChunk{Kind: SSRStreamChunkBoundary, BoundaryID: parseBoundary.id, Err: parseErr}
Expand All @@ -176,7 +182,14 @@ func renderElementToStreamShell(parseBuilder *strings.Builder, parseElement *Ele
}
}

if _, parseOk2 := parseElement.Type.(*ContextProviderType); parseOk2 {
if parseProvider, parseOk2 := parseElement.Type.(*ContextProviderType); parseOk2 {
if parseProvider.Descriptor != nil {
parsePrevCtx := parseState.contextValues
parseState.contextValues = deriveContextValues(parsePrevCtx, parseProvider.Descriptor.ID, parseElement.Props["value"])
parseErr := renderChildrenToStreamShell(parseBuilder, parseElement.Children, parseState)
parseState.contextValues = parsePrevCtx
return parseErr
}
return renderChildrenToStreamShell(parseBuilder, parseElement.Children, parseState)
}
if _, parseOk3 := parseElement.Type.(*PortalElementType); parseOk3 {
Expand Down Expand Up @@ -205,7 +218,7 @@ func renderElementToStreamShell(parseBuilder *strings.Builder, parseElement *Ele
return renderAsyncBoundaryToStreamShell(parseBuilder, parseElement, parseState)
}

parseResolved, parseErr := resolveComponentElement(parseElement, nil)
parseResolved, parseErr := resolveComponentElement(parseElement, parseState.contextValues)
if parseErr != nil {
return parseErr
}
Expand Down Expand Up @@ -299,9 +312,10 @@ func renderAsyncBoundaryToStreamShell(parseBuilder *strings.Builder, parseElemen
parseRollbackPending()
parseBoundaryID := parseState.nextSSRStreamBoundaryID()
parseState.pending = append(parseState.pending, ssrStreamPendingBoundary{
id: parseBoundaryID,
content: parseContent,
suspension: parseSuspension,
id: parseBoundaryID,
content: parseContent,
suspension: parseSuspension,
contextValues: parseState.contextValues,
})
return renderAsyncBoundaryFallbackToStreamShell(parseBuilder, parseElement, parseState, nil, parseBoundaryID)
}
Expand Down Expand Up @@ -358,7 +372,7 @@ func renderHostElementToStreamShell(parseBuilder *strings.Builder, parseTag stri
parseBuilder.WriteString(parseTag)
writeSSRProps(parseBuilder, parseElement.Props, "value")
parseBuilder.WriteByte('>')
if parseErr := renderSelectChildrenToString(parseBuilder, getElementChildren(parseElement), parseSelectValue, nil); parseErr != nil {
if parseErr := renderSelectChildrenToString(parseBuilder, getElementChildren(parseElement), parseSelectValue, parseState.contextValues); parseErr != nil {
return parseErr
}
parseBuilder.WriteString("</")
Expand Down
57 changes: 57 additions & 0 deletions internal/runtime/ssr_stream_hooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package runtime

import (
"context"
"fmt"
"strings"
"testing"
)

func streamToString(t *testing.T, parseEl *Element) string {
t.Helper()
var parseBuf strings.Builder
if parseErr := RenderToStream(context.Background(), &parseBuf, parseEl, SSRStreamOptions{}); parseErr != nil {
t.Fatalf("RenderToStream error: %v", parseErr)
}
return parseBuf.String()
}

// The streaming SSR renderer runs hooks (like the buffered path) and now threads
// context through the shell, so a streamed component's GoUseContextValue resolves
// to the nearest provider rather than the descriptor default.
func TestStreamRendersHooksAndContext(t *testing.T) {
// useState renders its initial value in the stream shell.
parseStateComponent := func() *Element {
parseGet, _ := GoUseStateGlobal(5)
return CreateElement("p", map[string]any{}, fmt.Sprint(parseGet()))
}
if parseGot := streamToString(t, CreateElement(parseStateComponent, map[string]any{})); parseGot != "<p>5</p>" {
t.Errorf("stream useState = %q, want <p>5</p>", parseGot)
}

parseDesc := NewContextDescriptor("DEFAULT")
parseProvider := NewContextProviderType(parseDesc)
parseConsumer := func() *Element {
return CreateElement("span", map[string]any{}, fmt.Sprint(GoUseContextValue(parseDesc)))
}

// Provider value reaches a consumer nested under a host element in the stream.
parseProvided := CreateElementOwned(parseProvider, map[string]any{"value": "PROVIDED"},
CreateElement("div", map[string]any{}, CreateElement(parseConsumer, map[string]any{})))
if parseGot := streamToString(t, parseProvided); parseGot != "<div><span>PROVIDED</span></div>" {
t.Errorf("stream useContext = %q, want <div><span>PROVIDED</span></div>", parseGot)
}

// Default when no provider.
if parseGot := streamToString(t, CreateElement(parseConsumer, map[string]any{})); parseGot != "<span>DEFAULT</span>" {
t.Errorf("stream useContext default = %q", parseGot)
}

// Nested provider overrides for the streamed subtree.
parseNested := CreateElementOwned(parseProvider, map[string]any{"value": "OUTER"},
CreateElementOwned(parseProvider, map[string]any{"value": "INNER"},
CreateElement(parseConsumer, map[string]any{})))
if parseGot := streamToString(t, parseNested); parseGot != "<span>INNER</span>" {
t.Errorf("stream nested provider = %q, want <span>INNER</span>", parseGot)
}
}