From 342dd80c486d34e49df8128f53a04b32931df108 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 23:28:28 -0400 Subject: [PATCH] feat(ssr): thread context to hooks in streaming SSR (v3.5.1) v3.5.0 ran hooks in the streaming path (RenderToStream) 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: it is derived at each ContextProvider boundary (deriveContextValues) and restored after that boundary's children, and captured per pending async boundary so deferred Suspense content resolves the same context. resolveComponentElement and the select renderer in the stream path now receive parseState.contextValues instead of nil. Streamed useContext now resolves to the nearest provider (incl. nested-provider override); useState/useRef/useMemo continue to work. Completes the SSR hooks work begun in v3.5.0. Covered by TestStreamRendersHooksAndContext; full suite passes, native + wasm build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 +++++ docs/REACT_TEST_PARITY.md | 2 +- .../assets/docs/latest-release-notes.md | 24 ++++---- internal/runtime/ssr_stream.go | 34 +++++++---- internal/runtime/ssr_stream_hooks_test.go | 57 +++++++++++++++++++ 5 files changed, 106 insertions(+), 24 deletions(-) create mode 100644 internal/runtime/ssr_stream_hooks_test.go 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("5

" { + t.Errorf("stream useState = %q, want

5

", 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 != "
PROVIDED
" { + t.Errorf("stream useContext = %q, want
PROVIDED
", parseGot) + } + + // Default when no provider. + if parseGot := streamToString(t, CreateElement(parseConsumer, map[string]any{})); parseGot != "DEFAULT" { + 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 != "INNER" { + t.Errorf("stream nested provider = %q, want INNER", parseGot) + } +}