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

## v3.5.2 - 2026-06-25

### Fixed

- **`defaultValue` / `defaultChecked` rendered as inert attributes in SSR.** An
uncontrolled form field set via `defaultValue` (or `defaultChecked`) serialized
the prop literally — `<input defaultValue="x">` — which the browser ignores, so
the field rendered empty/unchecked server-side. The SSR serializer now maps
`defaultValue` → the element's controlled value and `defaultChecked` → `checked`
(only when the controlled prop is not already set, so an explicit `value` wins),
in both the buffered and streaming renderers. Because the mapping happens before
the controlled-value logic, it flows through every form element: `<input>` gets
a `value` attribute, `<textarea>` gets text content, and `<select>` marks the
matching `<option selected>`. Matches React's
`ReactDOMServerIntegrationInput`/`Textarea`/`Select` defaultValue behavior and
completes the controlled-input SSR work (v3.4.9 textarea, v3.4.10 select).
Covered by `TestSSRDefaultValueMapsToControlledValue`.

## v3.5.1 - 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 @@ -47,7 +47,7 @@ re-renders/effects are observable inline. SSR behavior is tested via
| 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) |
| ReactDOMServerIntegrationTextarea/Input/Checkbox | controlled inputs SSR | ✅ | ui/ssr_controlled_inputs_test.go, ui/ssr_default_value_test.go (textarea v3.4.9; defaultValue/defaultChecked->controlled v3.5.2) |
| ReactDOMServerIntegrationSelect | select value -> selected option | ✅ | ui/ssr_select_test.go (found+fixed v3.4.10; match by value/text, optgroup) |
| 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 | ✅ | already covered natively: ~30 hydration test/fuzz funcs in internal/runtime (hydration_test.go, hydration_helper_gap_test.go, hydration_roundtrip_fuzz_test.go) |
Expand Down
23 changes: 12 additions & 11 deletions examples/public-examples-site/assets/docs/latest-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@
This docs-site page mirrors the first section returned by
`tools/changelogcheck.LatestEntry(CHANGELOG.md)`.

## v3.5.1 - 2026-06-24
## v3.5.2 - 2026-06-25

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.
The current latest changelog section is `v3.5.2 - 2026-06-25`. **Fixed:**
`defaultValue`/`defaultChecked` rendered as inert, browser-ignored attributes in
SSR, so an uncontrolled form field set via `defaultValue` rendered empty
server-side. The SSR serializer (buffered + streaming) now maps `defaultValue` →
the element's controlled value and `defaultChecked` → `checked` (unless the
controlled prop is already set, so an explicit `value` wins). Because it runs
before the controlled-value logic, it flows through every form element: `<input>`
gets a `value`, `<textarea>` gets text content, and `<select>` marks the matching
`<option selected>` — matching React and completing the controlled-input SSR work
(v3.4.9 textarea, v3.4.10 select). 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.
44 changes: 39 additions & 5 deletions internal/runtime/ssr.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,13 +324,47 @@ func renderSelectChildrenToString(parseBuilder *strings.Builder, parseChildren [
return nil
}

// normalizeFormValueProps maps React's uncontrolled form props to their
// controlled HTML equivalents for SSR: defaultValue -> value and
// defaultChecked -> checked (only when the controlled prop is not already set).
// Without this, defaultValue/defaultChecked serialize as browser-ignored
// attributes and the field renders empty/unchecked server-side. Returns the
// original map untouched when neither default* prop is present (the common case).
func normalizeFormValueProps(parseProps map[string]any) map[string]any {
if parseProps == nil {
return parseProps
}
_, parseHasDefaultValue := parseProps["defaultValue"]
_, parseHasDefaultChecked := parseProps["defaultChecked"]
if !parseHasDefaultValue && !parseHasDefaultChecked {
return parseProps
}
parseOut := make(map[string]any, len(parseProps))
maps.Copy(parseOut, parseProps)
if parseDV, parseOk := parseOut["defaultValue"]; parseOk {
if _, parseHasValue := parseOut["value"]; !parseHasValue {
parseOut["value"] = parseDV
}
delete(parseOut, "defaultValue")
}
if parseDC, parseOk := parseOut["defaultChecked"]; parseOk {
if _, parseHasChecked := parseOut["checked"]; !parseHasChecked {
parseOut["checked"] = parseDC
}
delete(parseOut, "defaultChecked")
}
return parseOut
}

func renderHostElementToString(parseBuilder *strings.Builder, parseTag string, parseElement *Element, parseCtx map[int64]any) error {
parseProps := normalizeFormValueProps(parseElement.Props)

// A controlled <textarea value="x"> renders its value as text content, not as
// a (browser-ignored) value attribute.
if parseTextareaValue, parseIsTextarea := textareaControlledValue(parseTag, parseElement.Props); parseIsTextarea {
if parseTextareaValue, parseIsTextarea := textareaControlledValue(parseTag, parseProps); parseIsTextarea {
parseBuilder.WriteByte('<')
parseBuilder.WriteString(parseTag)
writeSSRProps(parseBuilder, parseElement.Props, "value")
writeSSRProps(parseBuilder, parseProps, "value")
parseBuilder.WriteByte('>')
parseBuilder.WriteString(html.EscapeString(parseTextareaValue))
parseBuilder.WriteString("</")
Expand All @@ -341,10 +375,10 @@ func renderHostElementToString(parseBuilder *strings.Builder, parseTag string, p

// A controlled <select value="x"> marks the matching <option> as selected and
// drops the (browser-ignored) value attribute from the <select> itself.
if parseSelectValue, parseIsSelect := selectControlledValue(parseTag, parseElement.Props); parseIsSelect {
if parseSelectValue, parseIsSelect := selectControlledValue(parseTag, parseProps); parseIsSelect {
parseBuilder.WriteByte('<')
parseBuilder.WriteString(parseTag)
writeSSRProps(parseBuilder, parseElement.Props, "value")
writeSSRProps(parseBuilder, parseProps, "value")
parseBuilder.WriteByte('>')
if parseErr := renderSelectChildrenToString(parseBuilder, getElementChildren(parseElement), parseSelectValue, parseCtx); parseErr != nil {
return parseErr
Expand All @@ -357,7 +391,7 @@ func renderHostElementToString(parseBuilder *strings.Builder, parseTag string, p

parseBuilder.WriteByte('<')
parseBuilder.WriteString(parseTag)
writeSSRProps(parseBuilder, parseElement.Props)
writeSSRProps(parseBuilder, parseProps)
parseBuilder.WriteByte('>')

if isVoidElement(parseTag) {
Expand Down
12 changes: 7 additions & 5 deletions internal/runtime/ssr_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,12 +350,14 @@ func renderAsyncBoundaryFallbackToStreamShell(parseBuilder *strings.Builder, par
}

func renderHostElementToStreamShell(parseBuilder *strings.Builder, parseTag string, parseElement *Element, parseState *ssrStreamState) error {
parseProps := normalizeFormValueProps(parseElement.Props)

// Mirror the buffered serializer: a controlled <textarea value="x"> renders
// its value as text content, not as a browser-ignored value attribute.
if parseTextareaValue, parseIsTextarea := textareaControlledValue(parseTag, parseElement.Props); parseIsTextarea {
if parseTextareaValue, parseIsTextarea := textareaControlledValue(parseTag, parseProps); parseIsTextarea {
parseBuilder.WriteByte('<')
parseBuilder.WriteString(parseTag)
writeSSRProps(parseBuilder, parseElement.Props, "value")
writeSSRProps(parseBuilder, parseProps, "value")
parseBuilder.WriteByte('>')
parseBuilder.WriteString(html.EscapeString(parseTextareaValue))
parseBuilder.WriteString("</")
Expand All @@ -367,10 +369,10 @@ func renderHostElementToStreamShell(parseBuilder *strings.Builder, parseTag stri
// A controlled <select value="x"> marks the matching <option> selected and
// drops the value attribute from the select (options/optgroups don't suspend,
// so the buffered select-children renderer is reused here).
if parseSelectValue, parseIsSelect := selectControlledValue(parseTag, parseElement.Props); parseIsSelect {
if parseSelectValue, parseIsSelect := selectControlledValue(parseTag, parseProps); parseIsSelect {
parseBuilder.WriteByte('<')
parseBuilder.WriteString(parseTag)
writeSSRProps(parseBuilder, parseElement.Props, "value")
writeSSRProps(parseBuilder, parseProps, "value")
parseBuilder.WriteByte('>')
if parseErr := renderSelectChildrenToString(parseBuilder, getElementChildren(parseElement), parseSelectValue, parseState.contextValues); parseErr != nil {
return parseErr
Expand All @@ -383,7 +385,7 @@ func renderHostElementToStreamShell(parseBuilder *strings.Builder, parseTag stri

parseBuilder.WriteByte('<')
parseBuilder.WriteString(parseTag)
writeSSRProps(parseBuilder, parseElement.Props)
writeSSRProps(parseBuilder, parseProps)
parseBuilder.WriteByte('>')

if isVoidElement(parseTag) {
Expand Down
70 changes: 70 additions & 0 deletions ui/ssr_default_value_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//go:build !js || !wasm

package ui_test

import (
"testing"

"github.com/monstercameron/GoWebComponents/html"
"github.com/monstercameron/GoWebComponents/ui"
)

// Ported from React's ReactDOMServerIntegrationInput defaultValue / defaultChecked
// behavior: an uncontrolled form field's defaultValue is serialized as the
// element's controlled value (and defaultChecked as checked) so it renders with
// its initial value server-side instead of an inert, browser-ignored
// defaultValue/defaultChecked attribute. Regression test for the v3.5.2 fix.
func TestSSRDefaultValueMapsToControlledValue(t *testing.T) {
cases := []struct {
name string
node ui.Node
want string
}{
{
"input-defaultValue",
html.Input(html.Props{Type: "text", Raw: map[string]any{"defaultValue": "dv"}}),
`<input type="text" value="dv">`,
},
{
"value-overrides-defaultValue",
html.Input(html.Props{Type: "text", Value: "v", Raw: map[string]any{"defaultValue": "dv"}}),
`<input type="text" value="v">`,
},
{
"checkbox-defaultChecked",
html.Input(html.Props{Type: "checkbox", Raw: map[string]any{"defaultChecked": true}}),
`<input checked type="checkbox">`,
},
{
"checkbox-defaultChecked-false",
html.Input(html.Props{Type: "checkbox", Raw: map[string]any{"defaultChecked": false}}),
`<input type="checkbox">`,
},
{
"textarea-defaultValue-as-content",
html.Textarea(html.Props{Raw: map[string]any{"defaultValue": "body"}}),
`<textarea>body</textarea>`,
},
{
"select-defaultValue-selects-option",
html.Select(html.Props{Raw: map[string]any{"defaultValue": "b"}},
html.Option(html.Props{Value: "a"}, ui.Text("A")),
html.Option(html.Props{Value: "b"}, ui.Text("B"))),
`<select><option value="a">A</option><option selected value="b">B</option></select>`,
},
{
"no-default-unchanged",
html.Input(html.Props{Type: "text", Value: "v"}),
`<input type="text" value="v">`,
},
}
for _, parseCase := range cases {
out, err := ui.RenderToString(parseCase.node)
if err != nil {
t.Fatalf("%s: render error: %v", parseCase.name, err)
}
if out != parseCase.want {
t.Errorf("%s:\n got %q\nwant %q", parseCase.name, out, parseCase.want)
}
}
}