diff --git a/CHANGELOG.md b/CHANGELOG.md index f77149a2..28a98332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/REACT_TEST_PARITY.md b/docs/REACT_TEST_PARITY.md index 8bbc4fa5..ac2d671c 100644 --- a/docs/REACT_TEST_PARITY.md +++ b/docs/REACT_TEST_PARITY.md @@ -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) diff --git a/examples/public-examples-site/assets/docs/latest-release-notes.md b/examples/public-examples-site/assets/docs/latest-release-notes.md index e73b62ab..77d61b15 100644 --- a/examples/public-examples-site/assets/docs/latest-release-notes.md +++ b/examples/public-examples-site/assets/docs/latest-release-notes.md @@ -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. diff --git a/internal/runtime/ssr_stream.go b/internal/runtime/ssr_stream.go index 7f7349e3..5854895f 100644 --- a/internal/runtime/ssr_stream.go +++ b/internal/runtime/ssr_stream.go @@ -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 @@ -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} @@ -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 { @@ -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 } @@ -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) } @@ -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("") diff --git a/internal/runtime/ssr_stream_hooks_test.go b/internal/runtime/ssr_stream_hooks_test.go new file mode 100644 index 00000000..fbf1524c --- /dev/null +++ b/internal/runtime/ssr_stream_hooks_test.go @@ -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 != "
5
" { + t.Errorf("stream useState = %q, want5
", 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 != "