Skip to content

fix(compiler): AOT threads hydration id onto first element child of fragment roots [#2784]#2938

Merged
viniciusdacal merged 1 commit intomainfrom
fix/hydration-id-fragment-2784
Apr 22, 2026
Merged

fix(compiler): AOT threads hydration id onto first element child of fragment roots [#2784]#2938
viniciusdacal merged 1 commit intomainfrom
fix/hydration-id-fragment-2784

Conversation

@viniciusdacal
Copy link
Copy Markdown
Contributor

Closes #2784.

Summary

Interactive components (with a let declaration) that returned a JSX fragment silently lost their data-v-id marker. fragment_to_string had no hydration_id parameter, and expr_to_string passed the id into element_to_string but not into fragment_to_string. The client hydration walker then couldn't locate the component root, so event handlers and signal subscriptions never attached — an invisible failure for any component written as return <>...</>.

function Header() {
  let count = 0;
  return <>
    <h1>Title</h1>
    <button onClick={() => count++}>{count}</button>
  </>;
}
// Before: no data-v-id anywhere → hydration silently no-ops.
// After:  <h1 data-v-id="Header">...</h1><button>...</button>.

Policy

Option (a) from the issue: propagate the hydration id onto the first JSX element child of the fragment. Chosen over option (b) (reject fragment roots from AOT) because the runtime SSR path already does this implicitlyinject_hydration_attr (jsx_transformer.rs) uses a regex that skips document.createDocumentFragment() and targets the first const __elN = __element("..."). This fix brings the AOT path in line with runtime SSR so the two never diverge.

Changes

  • Thread hydration_id: Option<&str> through fragment_to_string and child_to_string in native/vertz-compiler-core/src/aot_string_transformer.rs.
  • Inside a fragment, the id attaches to the first DOM-element-or-fragment child. Nested fragments recurse. Text children, expression children, and component-tag children are skipped — a component like <Child/> compiles to __ssr_Child({}) (not an element that can carry attributes), so attaching the id there would lose it entirely. This matches the runtime regex, which only matches __element(...).
  • All non-root call sites (try_jsx_expr_to_string, expression_node_to_string, children_to_string of component_call_to_string) pass None — only the component root's fragment attaches the id.

Tests (BDD)

Added 8 scenarios in aot_string_transformer.rs covering every acceptance-criterion case from the issue plus the blockers surfaced in adversarial review:

  • Fragment with element first child → id on first element.
  • Fragment with text first child → id skips text, lands on next element.
  • Nested fragment → recurses into inner element.
  • Single-element fragment → id on the lone element.
  • Fragment with component first child → skips the component, lands on the first DOM element (regression-proofs the component-call path).
  • Fragment with only text/expression children → no element available, id silently dropped (matches runtime SSR; made explicit with a negative assertion).
  • Parenthesized fragment root return (<>...</>) → parens unwrapped, id still propagates.
  • Fragment with many children → defensive negative assertion that no sibling past the first eligible child receives the id.

All 1192 compiler-core unit tests and 360 NAPI-binding integration tests pass. Clippy and rustfmt clean. Pre-push hooks green.

Public API Changes

None. Internal compiler fix; no TS-facing API change.

Test plan

  • cargo test -p vertz-compiler-core --lib (1192 passed)
  • cargo test --all (all crates passed)
  • cargo clippy --all-targets -- -D warnings (clean)
  • cargo fmt --all -- --check (clean)
  • bun test __tests__/*.test.ts in native/vertz-compiler (360 passed)
  • vtz test packages/ui-server/src/__tests__ (978 passed)
  • Pre-push hooks: build-typecheck, lint, rust-clippy, rust-fmt, rust-test, test, trojan-source all green

🤖 Generated with Claude Code

@viniciusdacal viniciusdacal force-pushed the fix/hydration-id-fragment-2784 branch from 4828d24 to c964b2f Compare April 22, 2026 04:47
…ragment roots [#2784]

Interactive components (with a `let` declaration) that returned a JSX
fragment silently lost their `data-v-id` marker because `fragment_to_string`
had no hydration parameter and `expr_to_string` passed the id into
`element_to_string` but not into `fragment_to_string`. The client hydration
walker then couldn't locate the component root, so event handlers and
signal subscriptions never attached.

Threads `hydration_id: Option<&str>` through `fragment_to_string` and
`child_to_string`. Inside a fragment root, the id attaches to the first
DOM element child; nested fragments recurse; text, expression, and
component-tag children (which compile to `__ssr_Child({})` calls, not
`__element(...)`) are skipped. Matches runtime SSR, where
`inject_hydration_attr` already targets the first `__element(...)` and
skips `document.createDocumentFragment()`.

Covered by BDD scenarios for: element first child, text first child,
nested fragment, single-element fragment, component first child,
text/expression-only fragment, parenthesized fragment root, and a
negative assertion that later siblings never receive the id.

Closes #2784.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@viniciusdacal viniciusdacal force-pushed the fix/hydration-id-fragment-2784 branch from c964b2f to 691fd12 Compare April 22, 2026 04:54
@viniciusdacal viniciusdacal merged commit 682eb30 into main Apr 22, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

compiler(aot): hydration_id not threaded into fragment_to_string

1 participant