diff --git a/.changeset/ssr-component-names.md b/.changeset/ssr-component-names.md new file mode 100644 index 000000000..fd142b0ff --- /dev/null +++ b/.changeset/ssr-component-names.md @@ -0,0 +1,9 @@ +--- +"@solidjs/babel-plugin": patch +"@solidjs/compiler": patch +"solid-js": patch +--- + +`componentNames` now applies to SSR output. Under the option both compilers keep the `createComponent` call they otherwise inline to `Comp(props)` and pass the source tag name — `createComponent(Comp, props, "Comp")` — so the server runtime's observe/dev `createComponent` labels the owner and a server finding's `ownerPath` reads `` like the client's. Without the option (prod builds) SSR output is unchanged. `@solidjs/vite-plugin` already passes the option for its dev and observe postures, so app server builds pick this up with no config change. + +Fixes `ssrScope` under transparent owners: the virtual hole scope swapped the current owner's id counter, but content inside a hole resolves ids by walking past transparent owners, so with one in between (the server-component scope owner; now the labelled component owner) the hole's content took ids from the enclosing counter and disagreed with the client. The scope now swaps the nearest id-bearing owner. diff --git a/documentation/plans/server-dev-build-plan.md b/documentation/plans/server-dev-build-plan.md index f27f9f69c..51df7c121 100644 --- a/documentation/plans/server-dev-build-plan.md +++ b/documentation/plans/server-dev-build-plan.md @@ -196,13 +196,31 @@ Decision: **reuse `@solidjs/signals`'s channel, do not fork it.** > `packages/web/test/frames-marker-corruption.spec.tsx`. Docs: RFC 08 > "Server rendering" + quick reference; the reactivity-diagnostics skill. > -> Known gap, deliberately out of this PR: the SSR compiler inlines component -> calls (`Comp({})`) instead of `createComponent`, so compiled JSX does not -> get the label — `ownerPath` is populated for `createComponent` callers -> (the runtime's own flow components, `Dynamic`, tests) and empty for a -> plain compiled `` tree. The fix is the compiler emitting -> `createComponent` under the `componentNames` option for SSR output as it -> does for the client; tracked separately. +> Known gap at the time, closed in the follow-up (2026-09-14): the SSR +> compiler inlined component calls (`Comp({})`) instead of `createComponent`, +> so compiled JSX did not get the label — `ownerPath` was populated for +> `createComponent` callers (the runtime's own flow components, `Dynamic`, +> tests) and empty for a plain compiled `` tree. Both compilers now +> honour `componentNames` for the `ssr` generate the way they do for `dom`: +> the output keeps `createComponent(Comp, props, "Comp")` (the label has +> nowhere else to go; the prod server `createComponent` is that same +> `Comp(props)` call plus one frame), and without the option SSR still +> inlines. The vite plugin already passes `componentNames` for its dev and +> observe postures, so an app's server build labels every compiled component +> in exactly the builds whose runtime reads the argument; prod output is +> byte-identical to before. Boundaries are compiled components too, so a +> server finding raised by a boundary reads `` — as on the +> client. Landing this surfaced a latent `ssrScope` bug: the virtual hole +> scope swapped the CURRENT owner's `id`/`_childCount`, but content inside +> the hole resolves ids by walking up past transparent owners — so with a +> transparent owner in between (the server-component scope owner in every +> tier; now the labelled `` owner under every component body) the +> reserved slot was invisible and the hole's content took fresh ids from the +> enclosing counter (`_hk=3` where the client expects `_hk=10`). The scope +> now swaps the nearest id-bearing owner +> (`packages/solid/test/server/ssr-scope.spec.ts`). The web server suite +> compiles with `componentNames` (`vite.config.server.mjs`), so its +> hydration-id and diagnostics specs run against the labelled shape. - The server facade imports `DEV` (and the `emitDiagnostic` / `DiagnosticEvent` types) from `@solidjs/signals`, every use behind diff --git a/packages/babel-plugin/README.md b/packages/babel-plugin/README.md index 7c5d26b80..11078f4fd 100644 --- a/packages/babel-plugin/README.md +++ b/packages/babel-plugin/README.md @@ -132,7 +132,7 @@ Development output. With `hydratable`, emits the hydration walk validation helpe - Type: `boolean` - Default: `false` -DOM output only. Emit the tag as written in source as a third `createComponent` argument — `` compiles to `createComponent(Home, props, "Home")`, `` to `"Ui.Button"` — so the dev and observe runtimes label each component's owner (`` in diagnostic `ownerPath`s and attribution chains) even after a minifier renames the function or a `lazy()`/HMR wrapper hides it. The production runtime ignores the argument; SSR and universal output are unaffected. `@solidjs/vite-plugin` turns this on for its dev and `observe` postures. +Emit the tag as written in source as a third `createComponent` argument — `` compiles to `createComponent(Home, props, "Home")`, `` to `"Ui.Button"` — so the dev and observe runtimes label each component's owner (`` in diagnostic `ownerPath`s and attribution chains) even after a minifier renames the function or a `lazy()`/HMR wrapper hides it. Applies to DOM and SSR output; for SSR the compiler keeps the `createComponent` call it otherwise inlines to `Comp(props)`, so the server runtime labels the owner the same way (prod SSR output, without the option, is unchanged). Universal and dynamic output are unaffected. The production runtimes ignore the argument. `@solidjs/vite-plugin` turns this on for its dev and `observe` postures. ### delegateEvents diff --git a/packages/babel-plugin/src/config.ts b/packages/babel-plugin/src/config.ts index 7d5c13af3..c99e68174 100644 --- a/packages/babel-plugin/src/config.ts +++ b/packages/babel-plugin/src/config.ts @@ -18,8 +18,10 @@ export interface PluginConfig { dev: boolean; /** Emit the source tag name as a third `createComponent` argument * (`createComponent(Home, props, "Home")`) so dev/observe runtimes can - * label owners after minification renames the function. DOM output only; - * the production runtime ignores the argument. */ + * label owners after minification renames the function. DOM and SSR + * output (SSR keeps the `createComponent` call it would otherwise inline + * to `Comp(props)`); not universal or dynamic. The production runtimes + * ignore the argument. */ componentNames: boolean; delegateEvents: boolean; delegatedEvents: string[]; diff --git a/packages/babel-plugin/src/shared/component.ts b/packages/babel-plugin/src/shared/component.ts index 91846a91f..7885fc4fe 100644 --- a/packages/babel-plugin/src/shared/component.ts +++ b/packages/babel-plugin/src/shared/component.ts @@ -347,18 +347,27 @@ export default function transformComponent( const componentArgs = [tagId, props[0]]; // `componentNames` carries the source tag name into the call so the // dev/observe runtimes can label the owner after minification renames the - // function. DOM output only: SSR inlines the call below and the universal - // renderer's `createComponent` is user code with a two-argument contract. - if (config.componentNames && config.generate === "dom") { + // function — on the client (`createComponent` in solid-js's client entry) + // and on the server (its server entry's, which runs the body under a + // transparent `` owner in observe/dev so a server finding's + // `ownerPath` reads like the client's). Not for the universal renderer, + // whose `createComponent` is user code with a two-argument contract, nor + // the dynamic renderer's subtrees. + const labelled = + config.componentNames && (config.generate === "dom" || config.generate === "ssr"); + if (labelled) { componentArgs.push(t.stringLiteral(tagName)); } - // SSR's `createComponent` is literally `Comp(props || {})`. Since the + // SSR's prod `createComponent` is literally `Comp(props || {})`. Since the // compiler always emits a real `props[0]` object expression above (see the // `props.push(t.objectExpression(runningObject))` line), the `|| {}` fallback // never fires in compiled output. Inline to a direct `Comp(props)` call to // drop one function-call frame per component invocation. (DOM/dev modes // keep the wrapper since it does real work — `untrack`, dev metadata.) - if (config.generate === "ssr") { + // With `componentNames` the wrapper IS the work — the label has nowhere + // else to go — so SSR output keeps the call; the vite-plugin turns the + // option on for dev and observe builds only, so prod output stays inlined. + if (config.generate === "ssr" && !labelled) { exprs.push(t.callExpression(tagId, [props[0]])); } else { exprs.push(t.callExpression(registerImportMethod(path, "createComponent"), componentArgs)); diff --git a/packages/babel-plugin/test/__dom_component_names_fixtures__/ssr/code.js b/packages/babel-plugin/test/__dom_component_names_fixtures__/ssr/code.js new file mode 100644 index 000000000..d40df87df --- /dev/null +++ b/packages/babel-plugin/test/__dom_component_names_fixtures__/ssr/code.js @@ -0,0 +1,25 @@ +import { Child, Ui, Row } from "./components"; + +const Component = () => ; + +const template = ( +
+ + {name()} + + + text + {item => } + + + + + {() => } +
+); + +class Container { + render() { + return ; + } +} diff --git a/packages/babel-plugin/test/__dom_component_names_fixtures__/ssrInert/options.json b/packages/babel-plugin/test/__dom_component_names_fixtures__/ssr/options.json similarity index 100% rename from packages/babel-plugin/test/__dom_component_names_fixtures__/ssrInert/options.json rename to packages/babel-plugin/test/__dom_component_names_fixtures__/ssr/options.json diff --git a/packages/babel-plugin/test/__dom_component_names_fixtures__/ssr/output.js b/packages/babel-plugin/test/__dom_component_names_fixtures__/ssr/output.js new file mode 100644 index 000000000..f25c391b3 --- /dev/null +++ b/packages/babel-plugin/test/__dom_component_names_fixtures__/ssr/output.js @@ -0,0 +1,106 @@ +import { Show as _$Show } from "r-dom"; +import { For as _$For } from "r-dom"; +import { mergeProps as _$mergeProps } from "r-dom"; +import { ssr as _$ssr } from "r-dom"; +import { escape as _$escape } from "r-dom"; +import { createComponent as _$createComponent } from "r-dom"; +var _v$; +var _tmpl$ = ["", ""], + _tmpl$2 = ["
", "", "", "", "", "", "", "
"]; +import { Child, Ui, Row } from "./components"; +const Component = () => + _$createComponent( + Child, + { + name: "John" + }, + "Child" + ); +var _v$2 = _$escape( + _$createComponent( + Child, + _$mergeProps( + { + name: "Jane" + }, + props, + { + get children() { + return ((_v$ = () => _$escape(name())), _$ssr(_tmpl$, _v$)); + } + } + ), + "Child" + ) + ), + _v$3 = _$escape( + _$createComponent( + Ui.Button, + { + variant: "primary" + }, + "Ui.Button" + ) + ), + _v$4 = _$escape( + _$createComponent( + Ui.Layout.Grid, + { + cols: 2, + children: "text" + }, + "Ui.Layout.Grid" + ) + ), + _v$5 = _$escape( + _$createComponent( + _$For, + { + get each() { + return list(); + }, + children: item => + _$createComponent( + Row, + { + item: item + }, + "Row" + ) + }, + "For" + ) + ), + _v$6 = _$escape( + _$createComponent( + _$Show, + { + get when() { + return visible(); + }, + get children() { + return _$createComponent(Child, {}, "Child"); + } + }, + "Show" + ) + ), + _v$7 = _$escape(_$createComponent(_self$.Row, {}, "this.Row")), + _v$8 = _$escape( + _$createComponent( + Comp, + { + children: () => _$createComponent(Child, {}, "Child") + }, + "Comp" + ) + ); +const template = (() => { + const _self$ = this; + return _$ssr(_tmpl$2, _v$2, _v$3, _v$4, _v$5, _v$6, _v$7, _v$8); +})(); +class Container { + render() { + return _$createComponent(this.Row, {}, "this.Row"); + } +} diff --git a/packages/babel-plugin/test/__dom_component_names_fixtures__/ssrInert/code.js b/packages/babel-plugin/test/__dom_component_names_fixtures__/ssrInert/code.js deleted file mode 100644 index d860bcaf7..000000000 --- a/packages/babel-plugin/test/__dom_component_names_fixtures__/ssrInert/code.js +++ /dev/null @@ -1,8 +0,0 @@ -import { Child, Ui } from "./components"; - -const template = ( -
- - -
-); diff --git a/packages/babel-plugin/test/__dom_component_names_fixtures__/ssrInert/output.js b/packages/babel-plugin/test/__dom_component_names_fixtures__/ssrInert/output.js deleted file mode 100644 index c9b5c06f0..000000000 --- a/packages/babel-plugin/test/__dom_component_names_fixtures__/ssrInert/output.js +++ /dev/null @@ -1,15 +0,0 @@ -import { ssr as _$ssr } from "r-dom"; -import { escape as _$escape } from "r-dom"; -var _tmpl$ = ["
", "", "
"]; -import { Child, Ui } from "./components"; -var _v$ = _$escape( - Child({ - name: "Jane" - }) - ), - _v$2 = _$escape( - Ui.Button({ - variant: "primary" - }) - ); -const template = _$ssr(_tmpl$, _v$, _v$2); diff --git a/packages/compiler/README.md b/packages/compiler/README.md index f263f33ef..bb72999b8 100644 --- a/packages/compiler/README.md +++ b/packages/compiler/README.md @@ -113,7 +113,7 @@ Pass `sourceMap: true` to receive a JSON source map string in `result.map`. For - `generate`: `"dom"`, `"ssr"`, `"universal"`, or `"dynamic"` (default `"dom"`) - `hydratable` - `dev` -- `componentNames`: DOM output only — emit the source tag name as `createComponent`'s third argument (`createComponent(Home, props, "Home")`) so dev/observe runtimes label owners after minification; the production runtime ignores it +- `componentNames`: emit the source tag name as `createComponent`'s third argument (`createComponent(Home, props, "Home")`) so dev/observe runtimes label owners after minification; the production runtimes ignore it. DOM and SSR output (SSR keeps the `createComponent` call it otherwise inlines to `Comp(props)`); not universal or dynamic - `sourceMap` - `contextToCustomElements` (default `true`) - `delegateEvents` diff --git a/packages/compiler/__tests__/dom-component-names-fixtures.test.js b/packages/compiler/__tests__/dom-component-names-fixtures.test.js index 75a240bc2..c7097e9c0 100644 --- a/packages/compiler/__tests__/dom-component-names-fixtures.test.js +++ b/packages/compiler/__tests__/dom-component-names-fixtures.test.js @@ -10,7 +10,7 @@ const oxcFixtures = path.resolve(__dirname, "fixtures/dom-component-names"); const fixtureParity = { components: "subset", - ssrInert: "subset" + ssr: "subset" }; const suiteOptions = { @@ -83,4 +83,17 @@ describe("AST-native Babel DOM componentNames fixture reuse", () => { }); expect(code).not.toContain('"Child"'); }); + + // SSR keeps the `createComponent` wrapper only for the label; without the + // option it inlines `Comp(props)` and imports no `createComponent`. + it("SSR inlines the component call without the option", () => { + const { code } = transform(readFixture("ssr"), { + filename: "ssr.jsx", + ...fixtureOptions("ssr"), + componentNames: false + }); + expect(code).not.toContain("createComponent"); + expect(code).not.toContain('"Child"'); + expect(code).toContain("Child({"); + }); }); diff --git a/packages/compiler/__tests__/fixtures/dom-component-names/ssr/output.js b/packages/compiler/__tests__/fixtures/dom-component-names/ssr/output.js new file mode 100644 index 000000000..c05fcc2a3 --- /dev/null +++ b/packages/compiler/__tests__/fixtures/dom-component-names/ssr/output.js @@ -0,0 +1,49 @@ +import { escape as _$escape } from "r-dom"; +import { ssr as _$ssr } from "r-dom"; +import { mergeProps as _$mergeProps } from "r-dom"; +import { createComponent as _$createComponent } from "r-dom"; +import { For as _$For } from "r-dom"; +import { Show as _$Show } from "r-dom"; +var _v$; +var _tmpl$ = ["", ""]; +var _tmpl$2 = [ + "
", + "", + "", + "", + "", + "", + "", + "
" +]; +import { Child, Ui, Row } from "./components"; +const Component = () => _$createComponent(Child, { name: "John" }, "Child"); +const template = (() => { + var _v$2 = _$escape(_$createComponent(Child, _$mergeProps({ name: "Jane" }, props, { get children() { + return _v$ = () => { + return _$escape(name()); + }, _$ssr(_tmpl$, _v$); + } }), "Child")), _v$3 = _$escape(_$createComponent(Ui.Button, { variant: "primary" }, "Ui.Button")), _v$4 = _$escape(_$createComponent(Ui.Layout.Grid, { + cols: 2, + children: "text" + }, "Ui.Layout.Grid")), _v$5 = _$escape(_$createComponent(_$For, { + get each() { + return list(); + }, + children: (item) => _$createComponent(Row, { item }, "Row") + }, "For")), _v$6 = _$escape(_$createComponent(_$Show, { + get when() { + return visible(); + }, + get children() { + return _$createComponent(Child, {}, "Child"); + } + }, "Show")), _v$7 = _$escape(_$createComponent(_self$.Row, {}, "this.Row")), _v$8 = _$escape(_$createComponent(Comp, { children: () => _$createComponent(Child, {}, "Child") }, "Comp")); + const _self$ = this; + return _$ssr(_tmpl$2, _v$2, _v$3, _v$4, _v$5, _v$6, _v$7, _v$8); +})(); +class Container { + render() { + return _$createComponent(this.Row, {}, "this.Row"); + } +} diff --git a/packages/compiler/__tests__/fixtures/dom-component-names/ssrInert/output.js b/packages/compiler/__tests__/fixtures/dom-component-names/ssrInert/output.js deleted file mode 100644 index 73856b80a..000000000 --- a/packages/compiler/__tests__/fixtures/dom-component-names/ssrInert/output.js +++ /dev/null @@ -1,10 +0,0 @@ -import { escape as _$escape } from "r-dom"; -import { ssr as _$ssr } from "r-dom"; -var _tmpl$ = [ - "
", - "", - "
" -]; -import { Child, Ui } from "./components"; -var _v$ = _$escape(Child({ name: "Jane" })), _v$2 = _$escape(Ui.Button({ variant: "primary" })); -const template = _$ssr(_tmpl$, _v$, _v$2); diff --git a/packages/compiler/src/compiler.rs b/packages/compiler/src/compiler.rs index 747edf2ec..a59c1c910 100644 --- a/packages/compiler/src/compiler.rs +++ b/packages/compiler/src/compiler.rs @@ -309,6 +309,7 @@ fn compile_inner(source: &str, options: &CompileOptions) -> Result, /// Babel's `componentNames`: emit the source tag name as a third /// `createComponent` argument (`createComponent(Home, props, "Home")`) so - /// dev/observe runtimes can label owners after minification. DOM output - /// only; the production runtime ignores the argument. + /// dev/observe runtimes can label owners after minification. DOM and SSR + /// output (SSR keeps the `createComponent` call it would otherwise inline + /// to `Comp(props)`); not universal or dynamic. The production runtimes + /// ignore the argument. pub component_names: Option, pub source_map: Option, pub context_to_custom_elements: Option, diff --git a/packages/compiler/src/shared/component.rs b/packages/compiler/src/shared/component.rs index 14a9af2c8..b807d353e 100644 --- a/packages/compiler/src/shared/component.rs +++ b/packages/compiler/src/shared/component.rs @@ -31,10 +31,11 @@ pub(crate) trait ComponentLower<'a>: { /// Marks the `createComponent` helper as used. fn mark_create_component(&mut self); - /// Babel's `componentNames && generate === "dom"`: append the source tag - /// text as `createComponent`'s third argument. Only the DOM lowering - /// opts in; SSR inlines the call and universal renderers own their - /// two-argument `createComponent`. + /// Babel's `componentNames` on DOM output: append the source tag text as + /// `createComponent`'s third argument. The DOM lowering opts in here; the + /// SSR lowering has its own component path (ssr/transform.rs) and applies + /// the same option there; universal renderers own their two-argument + /// `createComponent` and never label. fn component_names_enabled(&self) -> bool { false } @@ -182,7 +183,10 @@ pub(crate) fn lower_component_with_setup<'a, C: ComponentLower<'a>>( let props = component_props_expression(ctx, element.span, prop_objects, force_merge_props); let mut args = vec![component, props]; if ctx.component_names_enabled() { - let name = jsx_tag_name(ctx, &element.opening_element.name); + let name = jsx_tag_name( + &|span| ctx.tag_identifier_is_this(span), + &element.opening_element.name, + ); args.push(ast.expression_string_literal(element.span, ast.str(&name), None)); } Ok(( @@ -192,10 +196,16 @@ pub(crate) fn lower_component_with_setup<'a, C: ComponentLower<'a>>( } /// The tag as written in source (`Home`, `Ui.Button`, `this.Row`) — the -/// label `componentNames` emits, matching Babel's `jsxTagName`. -fn jsx_tag_name<'a, C: ComponentLower<'a>>(ctx: &C, name: &JSXElementName<'a>) -> String { +/// label `componentNames` emits, matching Babel's `jsxTagName`. `is_this` +/// answers whether the identifier at a span was written as `this` in source +/// (the shared `this` pre-pass has already rewritten it to the `_self$` +/// capture, keeping the span). Shared by the DOM and SSR lowerings. +pub(crate) fn jsx_tag_name<'a>( + is_this: &dyn Fn(Span) -> bool, + name: &JSXElementName<'a>, +) -> String { let identifier_name = |name: &str, span: Span| { - if ctx.tag_identifier_is_this(span) { + if is_this(span) { "this".to_string() } else { name.to_string() @@ -208,7 +218,7 @@ fn jsx_tag_name<'a, C: ComponentLower<'a>>(ctx: &C, name: &JSXElementName<'a>) - JSXElementName::IdentifierReference(identifier) => { identifier_name(&identifier.name, identifier.span) } - JSXElementName::MemberExpression(member) => jsx_member_tag_name(ctx, member), + JSXElementName::MemberExpression(member) => jsx_member_tag_name(is_this, member), JSXElementName::ThisExpression(_) => "this".to_string(), JSXElementName::NamespacedName(namespaced) => { format!("{}:{}", namespaced.namespace.name, namespaced.name.name) @@ -216,19 +226,19 @@ fn jsx_tag_name<'a, C: ComponentLower<'a>>(ctx: &C, name: &JSXElementName<'a>) - } } -fn jsx_member_tag_name<'a, C: ComponentLower<'a>>( - ctx: &C, +fn jsx_member_tag_name<'a>( + is_this: &dyn Fn(Span) -> bool, member: &JSXMemberExpression<'a>, ) -> String { let object = match &member.object { JSXMemberExpressionObject::IdentifierReference(identifier) => { - if ctx.tag_identifier_is_this(identifier.span) { + if is_this(identifier.span) { "this".to_string() } else { identifier.name.to_string() } } - JSXMemberExpressionObject::MemberExpression(member) => jsx_member_tag_name(ctx, member), + JSXMemberExpressionObject::MemberExpression(member) => jsx_member_tag_name(is_this, member), JSXMemberExpressionObject::ThisExpression(_) => "this".to_string(), }; format!("{}.{}", object, member.property.name) diff --git a/packages/compiler/src/ssr/transform.rs b/packages/compiler/src/ssr/transform.rs index 2fa01f91f..f217a53e8 100644 --- a/packages/compiler/src/ssr/transform.rs +++ b/packages/compiler/src/ssr/transform.rs @@ -48,10 +48,15 @@ pub(crate) struct AstSsrTransform<'a, 'source> { hydratable: bool, server_components: bool, wrap_conditionals: bool, + /// Babel's `componentNames` on SSR output: keep the `createComponent` + /// call (instead of inlining `Comp(props)`) and pass the source tag text + /// as its third argument, so the server runtime labels the owner. + component_names: bool, /// The memo wrapper import name; `None` disables memo wrapping. memo_wrapper: Option, static_marker: String, uses_ssr: bool, + uses_create_component: bool, uses_ssr_hydration_key: bool, uses_ssr_select_values: bool, uses_escape: bool, @@ -189,6 +194,7 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { hydratable: bool, server_components: bool, wrap_conditionals: bool, + component_names: bool, memo_wrapper: Option, static_marker: String, built_ins: std::vec::Vec, @@ -202,9 +208,11 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { hydratable, server_components, wrap_conditionals, + component_names, memo_wrapper, static_marker, uses_ssr: false, + uses_create_component: false, uses_ssr_hydration_key: false, uses_ssr_select_values: false, uses_escape: false, @@ -451,6 +459,7 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { && !self.uses_escape && !self.uses_ssr_element && !self.uses_merge_props + && !self.uses_create_component && !self.uses_scope && !self.uses_memo && !self.uses_apply_ref @@ -501,6 +510,9 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { if self.uses_merge_props { statements.push(self.import_named("mergeProps", "_$mergeProps")); } + if self.uses_create_component { + statements.push(self.import_named("createComponent", "_$createComponent")); + } if self.uses_apply_ref { statements.push(self.import_named("applyRef", "_$applyRef")); } @@ -1037,7 +1049,27 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { flush_component_props(self, &mut running_props, &mut prop_objects, element.span); let props = component_props_expression(self, element.span, prop_objects, force_merge_props); - let call = self.call_expression(element.span, component, vec![props]); + // Babel: SSR inlines `createComponent(Comp, props)` to `Comp(props)` + // (the prod server wrapper is that call), except under + // `componentNames`, where the wrapper carries the label: + // `_$createComponent(Comp, props, "Comp")`. + let call = if self.component_names { + self.uses_create_component = true; + let name = crate::shared::component::jsx_tag_name( + &|span| self.source.get(span.start as usize..span.end as usize) == Some("this"), + &element.opening_element.name, + ); + let label = + self.ast() + .expression_string_literal(element.span, self.ast().str(&name), None); + self.call_identifier( + element.span, + "_$createComponent", + vec![component, props, label], + ) + } else { + self.call_expression(element.span, component, vec![props]) + }; if component_setup.is_empty() { return Ok(call); } diff --git a/packages/compiler/types.d.ts b/packages/compiler/types.d.ts index 9e8ee540d..bf2be68fd 100644 --- a/packages/compiler/types.d.ts +++ b/packages/compiler/types.d.ts @@ -15,8 +15,10 @@ export interface TransformOptions { /** * Emit the source tag name as a third `createComponent` argument * (`createComponent(Home, props, "Home")`) so dev/observe runtimes can - * label owners after minification renames the function. DOM output only; - * the production runtime ignores the argument. + * label owners after minification renames the function. DOM and SSR + * output (SSR keeps the `createComponent` call it would otherwise inline + * to `Comp(props)`); not universal or dynamic. The production runtimes + * ignore the argument. */ componentNames?: boolean; sourceMap?: boolean; diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index b57fc31af..829413d05 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -473,11 +473,21 @@ export function createRoot( * swapped around the evaluation. Content created during the evaluation * attaches to the parent owner, which matches the pre-scope disposal * semantics (boundary retries dispose it via the boundary owner). + * + * The swapped owner is the nearest ID-BEARING one, not necessarily the + * current one: content created inside the hole reads its ids through + * `nextChildIdFor`, which walks up past transparent owners, so a swap on a + * transparent owner (the server-component scope owner; in observe/dev, the + * labelled `` owner every component body runs under) would be + * invisible to it and the hole's content would take fresh ids from the + * enclosing counter — a different id than the client, which scopes the + * hole by its own insert effect regardless of what sits between. */ export function ssrScope(fn: () => T): () => unknown { - const parent = currentOwner; + let parent = currentOwner; // No id plumbing to protect (non-hydrating SSR / owner-less evaluation). if (!parent || parent.id == null) return fn; + while (parent._transparent && parent._parent) parent = parent._parent; const scopeId = nextChildIdFor(parent, true); return () => { const prevId = parent.id; diff --git a/packages/solid/test/server/ssr-scope.spec.ts b/packages/solid/test/server/ssr-scope.spec.ts new file mode 100644 index 000000000..9c68ff32f --- /dev/null +++ b/packages/solid/test/server/ssr-scope.spec.ts @@ -0,0 +1,107 @@ +/** @vitest-environment node */ +/** + * `ssrScope` — the virtual id scope around a deferred child hole — under + * TRANSPARENT owners. + * + * The scope is virtual: it swaps an owner's `id`/`_childCount` around the + * evaluation instead of allocating a hole owner, and content created inside + * reads its ids through `nextChildIdFor`, which walks UP past transparent + * owners to the nearest id-bearing one. The owner the scope swaps must be + * that same id-bearing owner; swapping whatever is current — a transparent + * owner, which the walk skips — makes the reserved slot invisible, and the + * hole's content takes fresh ids from the enclosing counter instead + * (`_hk=3` where the client, and the prod inline `Comp(props)` output, + * expect `_hk=10`). + * + * Two transparent owners sit between a component body and its id-bearing + * ancestor: the labelled `` owner the observe/dev `createComponent` + * runs a body under (every compiled component, since the compiler emits + * `createComponent` for SSR under `componentNames`), and the server-component + * scope owner in every tier. + */ +import { describe, expect, test } from "vitest"; +import { + createComponent, + createOwner, + createRoot, + getNextChildId, + getOwner, + runInServerComponentScope, + runWithOwner, + ssrScope +} from "../../src/server/index.js"; + +/** Ids the hole's content and its next sibling get, as `{hole}`. */ +function idsUnder(body: (hole: () => unknown) => unknown) { + const ids: string[] = []; + createRoot( + () => { + body(() => { + const inner = ssrScope(() => { + ids.push(getNextChildId(getOwner()!)); + ids.push(getNextChildId(getOwner()!)); + }); + inner(); + ids.push(getNextChildId(getOwner()!)); + }); + }, + { id: "" } + ); + return ids; +} + +describe("ssrScope under transparent owners", () => { + test("baseline: a direct owner reserves one slot and nests the hole's ids under it", () => { + // Slot "0" is the scope; its content is "00", "01"; the sibling is "1". + expect(idsUnder(hole => hole())).toEqual(["00", "01", "1"]); + }); + + test("a transparent owner between the hole and its id-bearing owner", () => { + expect(idsUnder(hole => runWithOwner(createOwner({ transparent: true }), hole))).toEqual([ + "00", + "01", + "1" + ]); + }); + + test("the server-component scope owner (every tier)", () => { + expect(idsUnder(hole => runInServerComponentScope(hole))).toEqual(["00", "01", "1"]); + }); + + test("the labelled component owner (observe/dev createComponent)", () => { + expect( + idsUnder(hole => + createComponent(function Parent() { + hole(); + return ""; + }, {}) + ) + ).toEqual(["00", "01", "1"]); + }); + + test("nested: a scope registered inside a scope, both through transparent owners", () => { + const ids: string[] = []; + createRoot( + () => { + runWithOwner(createOwner({ transparent: true }), () => { + const outer = ssrScope(() => { + runWithOwner(createOwner({ transparent: true }), () => { + ids.push(getNextChildId(getOwner()!)); + const inner = ssrScope(() => { + ids.push(getNextChildId(getOwner()!)); + }); + inner(); + ids.push(getNextChildId(getOwner()!)); + }); + }); + outer(); + ids.push(getNextChildId(getOwner()!)); + }); + }, + { id: "" } + ); + // Outer scope "0": first child "00", inner scope "01" with content "010", + // then "02"; the root continues at "1". + expect(ids).toEqual(["00", "010", "02", "1"]); + }); +}); diff --git a/packages/web/test/server/server-diagnostics.spec.tsx b/packages/web/test/server/server-diagnostics.spec.tsx index 1629ed053..75be44b52 100644 --- a/packages/web/test/server/server-diagnostics.spec.tsx +++ b/packages/web/test/server/server-diagnostics.spec.tsx @@ -18,10 +18,10 @@ // // Component labels (`ownerPath: ["", ""]`) come from the server // `createComponent`, which the observe/dev tiers run under a labelled -// transparent owner. The SSR compiler inlines `` to `Page({})` (no -// `createComponent` call), so the label sites here call `createComponent` -// directly — the shape compiled output takes once the compiler emits it for -// SSR under `componentNames`. +// transparent owner. This suite compiles with `componentNames` (see +// vite.config.server.mjs — the vite plugin's dev/observe postures), so a +// compiled `` is `createComponent(Page, {}, "Page")` rather than the +// prod inline `Page({})`; the labels here come from ordinary JSX. import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -37,7 +37,6 @@ import { } from "@solidjs/web"; import { OBSERVE, - createComponent, createMemo, lazy, NotReadyError, @@ -50,12 +49,6 @@ function delay(ms: number) { return new Promise(r => setTimeout(r, ms)); } -/** `` through the runtime's `createComponent` (labelled in observe/dev). */ -const mount = -

>(Comp: (props: P) => JSX.Element, props = {} as P) => - () => - createComponent(Comp, props); - function renderComplete(code: () => any, options: any = {}): Promise { return new Promise(resolve => { renderToStream(code, options).then(resolve); @@ -91,7 +84,7 @@ describe("SSR_RENDER_ERROR_CONTAINED (wiring)", () => { ); } - const html = await renderComplete(mount(App)); + const html = await renderComplete(() => ); expect(html).toContain("caught"); const [event, ...rest] = byCode("SSR_RENDER_ERROR_CONTAINED"); @@ -101,9 +94,12 @@ describe("SSR_RENDER_ERROR_CONTAINED (wiring)", () => { expect(event.message).toContain("Render error caught by : Error: bad render"); expect(event.data!.handling).toBe("fallback"); expect((event.data!.error as Error).message).toBe("bad render"); - // The server component wrapper labels its owner (``), the same - // field and walk as the client — the boundary sits inside . - expect(event.ownerPath).toEqual([""]); + // The server `createComponent` labels its owner (``), the same + // field and walk as the client — and, as on the client, the compiled + // `` is a component call too, so the boundary that caught the + // error is the innermost label (the finding is the boundary's, raised + // from its owner; the failed `` owner is already gone). + expect(event.ownerPath).toEqual(["", ""]); // Wiring in the dev tier: the channel got it AND the console face // reported it once, with the location — a developer sees the contained // error `renderToStream`'s `onError` never hears. (The observe tier @@ -129,14 +125,14 @@ describe("SSR_RENDER_ERROR_CONTAINED (wiring)", () => { ); } - await renderComplete(mount(App)); + await renderComplete(() => ); const events = byCode("SSR_RENDER_ERROR_CONTAINED"); expect(events.length).toBeGreaterThanOrEqual(1); expect(events.every(e => e.data!.handling === "client")).toBe(true); expect(events[0].message).toContain("the fragment rejected and the client re-renders it"); expect(events[0].message).toContain("late-boom"); - expect(events[0].ownerPath).toEqual([""]); + expect(events[0].ownerPath).toEqual(["", ""]); expect(typeof events[0].data!.boundary).toBe("string"); }); @@ -323,9 +319,9 @@ describe("dev checks: emitted and reported once", () => { return

page

; } function App() { - return createComponent(Page, {}); + return ; } - renderToString(mount(App)); + renderToString(() => ); const [event, ...rest] = byCode("HEAD_TAG_INVALID"); expect(rest).toHaveLength(0); @@ -381,7 +377,7 @@ describe("dev checks: emitted and reported once", () => { function App() { return
{{ not: "a template" } as any}
; } - const html = renderToString(mount(App)); + const html = renderToString(() => ); expect(html).toContain("` reaches the server `createComponent` with its label + // and diagnostics carry `ownerPath` (server-diagnostics.spec.tsx pins it). + plugins: [ + solidPlugin({ + compiler, + solid: { generate: "ssr", hydratable: true, componentNames: true } + }) + ], test: { environment: "node", include: ["test/server/**/*.spec.tsx"], globals: true, - pool: "threads", + pool: "threads" }, resolve: { conditions: ["node"], @@ -29,7 +37,7 @@ export default defineConfig({ "@solidjs/web/serialization/decode": resolve(rootDir, "serialization/dist/decode.js"), "@solidjs/web/serialization": resolve(rootDir, "serialization/dist/serialization.js"), "@solidjs/web": resolve(rootDir, "src/index.server.ts"), - "solid-js": resolve(rootDir, "../solid/src/server/index.ts"), + "solid-js": resolve(rootDir, "../solid/src/server/index.ts") } } });