diff --git a/.changeset/merge-sibling-templates.md b/.changeset/merge-sibling-templates.md new file mode 100644 index 000000000..ac4a9ea06 --- /dev/null +++ b/.changeset/merge-sibling-templates.md @@ -0,0 +1,7 @@ +--- +"@solidjs/babel-plugin": patch +"@solidjs/compiler": patch +"@solidjs/web": patch +--- + +Merge adjacent fully static DOM roots in JSX fragments into a single template clone. diff --git a/packages/babel-plugin/src/dom/template.ts b/packages/babel-plugin/src/dom/template.ts index 1306447da..3c69ee091 100644 --- a/packages/babel-plugin/src/dom/template.ts +++ b/packages/babel-plugin/src/dom/template.ts @@ -83,7 +83,10 @@ export function appendTemplates(path: NodePath, templates: TemplateRe raw: escapeStringForTemplate(templateText) }; - const flag = template.isWrapped ? 2 : template.isImportNode ? 1 : null; + const flag = + (template.isImportNode ? 1 : 0) | + (template.isWrapped ? 2 : 0) | + (template.isMultiRoot ? 4 : 0); return t.variableDeclarator( template.id, @@ -121,6 +124,7 @@ function registerTemplate(path: NodePath, results: TransformResult) { templateWithClosingTags: results.templateWithClosingTags as string, isImportNode: results.isImportNode, isWrapped: results.isWrapped, + isMultiRoot: results.isMultiRoot, renderer: "dom", // templates dedupe on markup, so the FIRST site carries the blame // for a validate failure (#3099) — good enough: every site with diff --git a/packages/babel-plugin/src/shared/fragment.ts b/packages/babel-plugin/src/shared/fragment.ts index 600b57aa2..2e9094977 100644 --- a/packages/babel-plugin/src/shared/fragment.ts +++ b/packages/babel-plugin/src/shared/fragment.ts @@ -2,36 +2,99 @@ import * as t from "@babel/types"; import { decode } from "html-entities"; import { filterChildren, trimWhitespace, checkLength } from "./utils"; import { transformNode, getCreateTemplate } from "./transform"; +import { VoidElements } from "../../../web/src/constants.js"; import type { NodePath } from "@babel/traverse"; import type { PluginConfig } from "../config"; import type { JSXNode, TransformResult } from "../types"; +type FragmentTemplate = { + path: NodePath; + result: TransformResult; +}; + +function isMergeableStaticDOMTemplate(result: TransformResult, config: PluginConfig) { + return ( + !config.hydratable && + result.renderer === "dom" && + !!result.id && + !!result.tagName && + typeof result.template === "string" && + !result.skipTemplate && + !result.isWrapped && + result.declarations.length === 0 && + result.exprs.length === 0 && + result.dynamics.length === 0 && + !result.postExprs?.length + ); +} + +function closeRootTemplate(result: TransformResult) { + const template = result.template as string; + const close = ``; + return VoidElements.has(result.tagName!) || template.endsWith(close) + ? template + : template + close; +} + +function createFragmentTemplate(templates: FragmentTemplate[], config: PluginConfig): t.Expression { + if (templates.length === 1) { + const { path, result } = templates[0]; + return getCreateTemplate(config, path, result)(path, result, true) as t.Expression; + } + + const first = templates[0]; + const result: TransformResult = { + template: templates.map(({ result }) => closeRootTemplate(result)).join(""), + templateWithClosingTags: templates + .map(({ result }) => result.templateWithClosingTags || result.template) + .join(""), + declarations: [], + exprs: [], + dynamics: [], + postExprs: [], + id: first.result.id, + tagName: first.result.tagName, + renderer: "dom", + isImportNode: templates.some(({ result }) => result.isImportNode), + isMultiRoot: true + }; + return getCreateTemplate(config, first.path, result)(first.path, result, true) as t.Expression; +} + export default function transformFragmentChildren( children: NodePath[], results: TransformResult, config: PluginConfig ) { const filteredChildren = filterChildren(children), - childNodes = filteredChildren.reduce((memo: t.Expression[], path: NodePath) => { - if (t.isJSXText(path.node)) { - const v = decode(trimWhitespace((path.node.extra?.raw as string | undefined) ?? "")); - if (v.length) memo.push(t.stringLiteral(v)); - } else { - const child = transformNode(path, { - topLevel: true, - fragmentChild: true, - lastElement: true - }); - if (child) - memo.push( - getCreateTemplate(config, path, child as TransformResult)( - path, - child as TransformResult, - true - ) as t.Expression - ); + childNodes: t.Expression[] = []; + let templates: FragmentTemplate[] = []; + const flushTemplates = () => { + if (!templates.length) return; + childNodes.push(createFragmentTemplate(templates, config)); + templates = []; + }; + + filteredChildren.forEach((path: NodePath) => { + if (t.isJSXText(path.node)) { + flushTemplates(); + const v = decode(trimWhitespace((path.node.extra?.raw as string | undefined) ?? "")); + if (v.length) childNodes.push(t.stringLiteral(v)); + } else { + const child = transformNode(path, { + topLevel: true, + fragmentChild: true, + lastElement: true + }); + if (!child) return; + if (isMergeableStaticDOMTemplate(child, config)) { + templates.push({ path, result: child }); + return; } - return memo; - }, []); + flushTemplates(); + childNodes.push(getCreateTemplate(config, path, child)(path, child, true) as t.Expression); + } + }); + flushTemplates(); results.exprs.push(childNodes.length === 1 ? childNodes[0] : t.arrayExpression(childNodes)); } diff --git a/packages/babel-plugin/src/types.ts b/packages/babel-plugin/src/types.ts index ac74d0c35..488a7575c 100644 --- a/packages/babel-plugin/src/types.ts +++ b/packages/babel-plugin/src/types.ts @@ -13,6 +13,7 @@ export interface TemplateRecord { templateWithClosingTags?: string | t.Expression | t.ArrayExpression; isImportNode?: boolean; isWrapped?: boolean; + isMultiRoot?: boolean; renderer: RendererName; /** First registration site, so `validate` failures point at the JSX (#3099). */ path?: NodePath; @@ -76,6 +77,7 @@ export interface TransformResult { renderer?: RendererName; isImportNode?: boolean; isWrapped?: boolean; + isMultiRoot?: boolean; skipTemplate?: boolean; templateWithClosingTags?: string; children?: TransformResult[]; diff --git a/packages/babel-plugin/test/__dom_fixtures__/fragments/code.js b/packages/babel-plugin/test/__dom_fixtures__/fragments/code.js index 0b6021e44..c704af6b3 100644 --- a/packages/babel-plugin/test/__dom_fixtures__/fragments/code.js +++ b/packages/babel-plugin/test/__dom_fixtures__/fragments/code.js @@ -81,3 +81,34 @@ const multiLineTrailing = ( 3 ); + +const groupedAroundExpression = ( + <> +
First
+
Second
+ {inserted} + Third +
Fourth
+ +); + +const adjacentDynamicRoots = ( + <> +
First
+
Last
+ +); + +const adjacentVoidRoots = ( + <> + + + +); + +const adjacentImportRoots = ( + <> + + `, 5); +const multiStatic = _tmpl$(); +const multiExpression = [_tmpl$2(), inserted, _tmpl$3(), "After"]; const multiDynamic = [ (() => { - var _el$5 = _tmpl$(); + var _el$5 = _tmpl$2(); _$effect( () => state.first, _v$ => { @@ -24,7 +30,7 @@ const multiDynamic = [ })(), _$memo(() => state.inserted), (() => { - var _el$6 = _tmpl$2(); + var _el$6 = _tmpl$3(); _$effect( () => state.last, _v$ => { @@ -37,11 +43,36 @@ const multiDynamic = [ ]; const singleExpression = inserted; const singleDynamic = _$memo(inserted); -const firstStatic = [inserted, _tmpl$3()]; -const firstDynamic = [_$memo(inserted), _tmpl$3()]; -const firstComponent = [_$createComponent(Component, {}), _tmpl$3()]; -const lastStatic = [_tmpl$3(), inserted]; -const lastDynamic = [_tmpl$3(), _$memo(inserted)]; -const lastComponent = [_tmpl$3(), _$createComponent(Component, {})]; -const spaces = [_tmpl$4(), " ", _tmpl$5(), " ", _tmpl$6()]; -const multiLineTrailing = [_tmpl$4(), _tmpl$5(), _tmpl$6()]; +const firstStatic = [inserted, _tmpl$4()]; +const firstDynamic = [_$memo(inserted), _tmpl$4()]; +const firstComponent = [_$createComponent(Component, {}), _tmpl$4()]; +const lastStatic = [_tmpl$4(), inserted]; +const lastDynamic = [_tmpl$4(), _$memo(inserted)]; +const lastComponent = [_tmpl$4(), _$createComponent(Component, {})]; +const spaces = [_tmpl$5(), " ", _tmpl$6(), " ", _tmpl$7()]; +const multiLineTrailing = _tmpl$8(); +const groupedAroundExpression = [_tmpl$9(), inserted, _tmpl$0()]; +const adjacentDynamicRoots = [ + (() => { + var _el$21 = _tmpl$2(); + _$effect( + () => state.first, + _v$ => { + _$setAttribute(_el$21, "id", _v$); + } + ); + return _el$21; + })(), + (() => { + var _el$22 = _tmpl$3(); + _$effect( + () => state.last, + _v$ => { + _$setAttribute(_el$22, "id", _v$); + } + ); + return _el$22; + })() +]; +const adjacentVoidRoots = _tmpl$1(); +const adjacentImportRoots = _tmpl$10(); diff --git a/packages/babel-plugin/test/__dom_wrapperless_fixtures__/fragments/output.js b/packages/babel-plugin/test/__dom_wrapperless_fixtures__/fragments/output.js index b9a681f29..25e64563b 100644 --- a/packages/babel-plugin/test/__dom_wrapperless_fixtures__/fragments/output.js +++ b/packages/babel-plugin/test/__dom_wrapperless_fixtures__/fragments/output.js @@ -1,20 +1,21 @@ import { template as _$template } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; import { setAttribute as _$setAttribute } from "r-dom"; -var _tmpl$ = /*#__PURE__*/ _$template(`
First`), - _tmpl$2 = /*#__PURE__*/ _$template(`
Last`), - _tmpl$3 = /*#__PURE__*/ _$template(`
`); -const multiStatic = [_tmpl$(), _tmpl$2()]; -const multiExpression = [_tmpl$(), inserted, _tmpl$2(), "After"]; +var _tmpl$ = /*#__PURE__*/ _$template(`
First
Last
`, 4), + _tmpl$2 = /*#__PURE__*/ _$template(`
First`), + _tmpl$3 = /*#__PURE__*/ _$template(`
Last`), + _tmpl$4 = /*#__PURE__*/ _$template(`
`); +const multiStatic = _tmpl$(); +const multiExpression = [_tmpl$2(), inserted, _tmpl$3(), "After"]; const multiDynamic = [ (() => { - var _el$5 = _tmpl$(); + var _el$5 = _tmpl$2(); _$setAttribute(_el$5, "id", state.first); return _el$5; })(), () => state.inserted, (() => { - var _el$6 = _tmpl$2(); + var _el$6 = _tmpl$3(); _$setAttribute(_el$6, "id", state.last); return _el$6; })(), @@ -22,9 +23,9 @@ const multiDynamic = [ ]; const singleExpression = inserted; const singleDynamic = inserted; -const firstStatic = [inserted, _tmpl$3()]; -const firstDynamic = [inserted, _tmpl$3()]; -const firstComponent = [_$createComponent(Component, {}), _tmpl$3()]; -const lastStatic = [_tmpl$3(), inserted]; -const lastDynamic = [_tmpl$3(), inserted]; -const lastComponent = [_tmpl$3(), _$createComponent(Component, {})]; +const firstStatic = [inserted, _tmpl$4()]; +const firstDynamic = [inserted, _tmpl$4()]; +const firstComponent = [_$createComponent(Component, {}), _tmpl$4()]; +const lastStatic = [_tmpl$4(), inserted]; +const lastDynamic = [_tmpl$4(), inserted]; +const lastComponent = [_tmpl$4(), _$createComponent(Component, {})]; diff --git a/packages/babel-plugin/test/__dynamic_fixtures__/fragments/output.js b/packages/babel-plugin/test/__dynamic_fixtures__/fragments/output.js index 150b1a885..71a10705a 100644 --- a/packages/babel-plugin/test/__dynamic_fixtures__/fragments/output.js +++ b/packages/babel-plugin/test/__dynamic_fixtures__/fragments/output.js @@ -3,17 +3,19 @@ import { createComponent as _$createComponent } from "r-custom"; import { memo as _$memo } from "r-custom"; import { setAttribute as _$setAttribute } from "r-dom"; import { effect as _$effect } from "r-custom"; -var _tmpl$ = /*#__PURE__*/ _$template(`
First`), - _tmpl$2 = /*#__PURE__*/ _$template(`
Last`), - _tmpl$3 = /*#__PURE__*/ _$template(`
`), - _tmpl$4 = /*#__PURE__*/ _$template(`1`), - _tmpl$5 = /*#__PURE__*/ _$template(`2`), - _tmpl$6 = /*#__PURE__*/ _$template(`3`); -const multiStatic = [_tmpl$(), _tmpl$2()]; -const multiExpression = [_tmpl$(), inserted, _tmpl$2(), "After"]; +var _tmpl$ = /*#__PURE__*/ _$template(`
First
Last
`, 4), + _tmpl$2 = /*#__PURE__*/ _$template(`
First`), + _tmpl$3 = /*#__PURE__*/ _$template(`
Last`), + _tmpl$4 = /*#__PURE__*/ _$template(`
`), + _tmpl$5 = /*#__PURE__*/ _$template(`1`), + _tmpl$6 = /*#__PURE__*/ _$template(`2`), + _tmpl$7 = /*#__PURE__*/ _$template(`3`), + _tmpl$8 = /*#__PURE__*/ _$template(`123`, 4); +const multiStatic = _tmpl$(); +const multiExpression = [_tmpl$2(), inserted, _tmpl$3(), "After"]; const multiDynamic = [ (() => { - var _el$5 = _tmpl$(); + var _el$5 = _tmpl$2(); _$effect( () => state.first, _v$ => { @@ -24,7 +26,7 @@ const multiDynamic = [ })(), _$memo(() => state.inserted), (() => { - var _el$6 = _tmpl$2(); + var _el$6 = _tmpl$3(); _$effect( () => state.last, _v$ => { @@ -39,11 +41,11 @@ const singleExpression = inserted; const singleDynamic = _$memo(inserted); const greeting = x => "Hello " + x; const singleTemplateLiteral = _$memo(() => greeting`world`); -const firstStatic = [inserted, _tmpl$3()]; -const firstDynamic = [_$memo(inserted), _tmpl$3()]; -const firstComponent = [_$createComponent(Component, {}), _tmpl$3()]; -const lastStatic = [_tmpl$3(), inserted]; -const lastDynamic = [_tmpl$3(), _$memo(inserted)]; -const lastComponent = [_tmpl$3(), _$createComponent(Component, {})]; -const spaces = [_tmpl$4(), " ", _tmpl$5(), " ", _tmpl$6()]; -const multiLineTrailing = [_tmpl$4(), _tmpl$5(), _tmpl$6()]; +const firstStatic = [inserted, _tmpl$4()]; +const firstDynamic = [_$memo(inserted), _tmpl$4()]; +const firstComponent = [_$createComponent(Component, {}), _tmpl$4()]; +const lastStatic = [_tmpl$4(), inserted]; +const lastDynamic = [_tmpl$4(), _$memo(inserted)]; +const lastComponent = [_tmpl$4(), _$createComponent(Component, {})]; +const spaces = [_tmpl$5(), " ", _tmpl$6(), " ", _tmpl$7()]; +const multiLineTrailing = _tmpl$8(); diff --git a/packages/compiler/__tests__/fixtures/dom-wrapperless/fragments/output.js b/packages/compiler/__tests__/fixtures/dom-wrapperless/fragments/output.js index 6eebd3365..7a2a0492a 100644 --- a/packages/compiler/__tests__/fixtures/dom-wrapperless/fragments/output.js +++ b/packages/compiler/__tests__/fixtures/dom-wrapperless/fragments/output.js @@ -1,19 +1,20 @@ import { template as _$template } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; import { setAttribute as _$setAttribute } from "r-dom"; -var _tmpl$ = /* @__PURE__ */ _$template(`
First`); -var _tmpl$2 = /* @__PURE__ */ _$template(`
Last`); -var _tmpl$3 = /* @__PURE__ */ _$template(`
`); -const multiStatic = [_tmpl$(), _tmpl$2()]; +var _tmpl$ = /* @__PURE__ */ _$template(`
First
Last
`, 4); +var _tmpl$2 = /* @__PURE__ */ _$template(`
First`); +var _tmpl$3 = /* @__PURE__ */ _$template(`
Last`); +var _tmpl$4 = /* @__PURE__ */ _$template(`
`); +const multiStatic = _tmpl$(); const multiExpression = [ - _tmpl$(), - inserted, _tmpl$2(), + inserted, + _tmpl$3(), "After" ]; const multiDynamic = [ (() => { - var _el$5 = _tmpl$(); + var _el$5 = _tmpl$2(); _$setAttribute(_el$5, "id", state.first); return _el$5; })(), @@ -21,7 +22,7 @@ const multiDynamic = [ return state.inserted; }, (() => { - var _el$6 = _tmpl$2(); + var _el$6 = _tmpl$3(); _$setAttribute(_el$6, "id", state.last); return _el$6; })(), @@ -29,9 +30,9 @@ const multiDynamic = [ ]; const singleExpression = inserted; const singleDynamic = inserted; -const firstStatic = [inserted, _tmpl$3()]; -const firstDynamic = [inserted, _tmpl$3()]; -const firstComponent = [_$createComponent(Component, {}), _tmpl$3()]; -const lastStatic = [_tmpl$3(), inserted]; -const lastDynamic = [_tmpl$3(), inserted]; -const lastComponent = [_tmpl$3(), _$createComponent(Component, {})]; +const firstStatic = [inserted, _tmpl$4()]; +const firstDynamic = [inserted, _tmpl$4()]; +const firstComponent = [_$createComponent(Component, {}), _tmpl$4()]; +const lastStatic = [_tmpl$4(), inserted]; +const lastDynamic = [_tmpl$4(), inserted]; +const lastComponent = [_tmpl$4(), _$createComponent(Component, {})]; diff --git a/packages/compiler/__tests__/fixtures/dom/fragments/output.js b/packages/compiler/__tests__/fixtures/dom/fragments/output.js index 68fc67ea3..994dcc451 100644 --- a/packages/compiler/__tests__/fixtures/dom/fragments/output.js +++ b/packages/compiler/__tests__/fixtures/dom/fragments/output.js @@ -3,22 +3,28 @@ import { memo as _$memo } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; import { effect as _$effect } from "r-dom"; import { setAttribute as _$setAttribute } from "r-dom"; -var _tmpl$ = /* @__PURE__ */ _$template(`
First`); -var _tmpl$2 = /* @__PURE__ */ _$template(`
Last`); -var _tmpl$3 = /* @__PURE__ */ _$template(`
`); -var _tmpl$4 = /* @__PURE__ */ _$template(`1`); -var _tmpl$5 = /* @__PURE__ */ _$template(`2`); -var _tmpl$6 = /* @__PURE__ */ _$template(`3`); -const multiStatic = [_tmpl$(), _tmpl$2()]; +var _tmpl$ = /* @__PURE__ */ _$template(`
First
Last
`, 4); +var _tmpl$2 = /* @__PURE__ */ _$template(`
First`); +var _tmpl$3 = /* @__PURE__ */ _$template(`
Last`); +var _tmpl$4 = /* @__PURE__ */ _$template(`
`); +var _tmpl$5 = /* @__PURE__ */ _$template(`1`); +var _tmpl$6 = /* @__PURE__ */ _$template(`2`); +var _tmpl$7 = /* @__PURE__ */ _$template(`3`); +var _tmpl$8 = /* @__PURE__ */ _$template(`123`, 4); +var _tmpl$9 = /* @__PURE__ */ _$template(`
First
Second
`, 4); +var _tmpl$10 = /* @__PURE__ */ _$template(`Third
Fourth
`, 4); +var _tmpl$11 = /* @__PURE__ */ _$template(``, 4); +var _tmpl$12 = /* @__PURE__ */ _$template(``, 5); +const multiStatic = _tmpl$(); const multiExpression = [ - _tmpl$(), - inserted, _tmpl$2(), + inserted, + _tmpl$3(), "After" ]; const multiDynamic = [ (() => { - var _el$5 = _tmpl$(); + var _el$5 = _tmpl$2(); _$effect(() => state.first, (_v$) => { _$setAttribute(_el$5, "id", _v$); }); @@ -28,7 +34,7 @@ const multiDynamic = [ return state.inserted; }), (() => { - var _el$6 = _tmpl$2(); + var _el$6 = _tmpl$3(); _$effect(() => state.last, (_v$) => { _$setAttribute(_el$6, "id", _v$); }); @@ -38,21 +44,37 @@ const multiDynamic = [ ]; const singleExpression = inserted; const singleDynamic = _$memo(inserted); -const firstStatic = [inserted, _tmpl$3()]; -const firstDynamic = [_$memo(inserted), _tmpl$3()]; -const firstComponent = [_$createComponent(Component, {}), _tmpl$3()]; -const lastStatic = [_tmpl$3(), inserted]; -const lastDynamic = [_tmpl$3(), _$memo(inserted)]; -const lastComponent = [_tmpl$3(), _$createComponent(Component, {})]; +const firstStatic = [inserted, _tmpl$4()]; +const firstDynamic = [_$memo(inserted), _tmpl$4()]; +const firstComponent = [_$createComponent(Component, {}), _tmpl$4()]; +const lastStatic = [_tmpl$4(), inserted]; +const lastDynamic = [_tmpl$4(), _$memo(inserted)]; +const lastComponent = [_tmpl$4(), _$createComponent(Component, {})]; const spaces = [ - _tmpl$4(), - " ", _tmpl$5(), " ", - _tmpl$6() + _tmpl$6(), + " ", + _tmpl$7() ]; -const multiLineTrailing = [ - _tmpl$4(), - _tmpl$5(), - _tmpl$6() +const multiLineTrailing = _tmpl$8(); +const groupedAroundExpression = [ + _tmpl$9(), + inserted, + _tmpl$10() ]; +const adjacentDynamicRoots = [(() => { + var _el$23 = _tmpl$2(); + _$effect(() => state.first, (_v$) => { + _$setAttribute(_el$23, "id", _v$); + }); + return _el$23; +})(), (() => { + var _el$24 = _tmpl$3(); + _$effect(() => state.last, (_v$) => { + _$setAttribute(_el$24, "id", _v$); + }); + return _el$24; +})()]; +const adjacentVoidRoots = _tmpl$11(); +const adjacentImportRoots = _tmpl$12(); diff --git a/packages/compiler/__tests__/fixtures/dynamic/fragments/output.js b/packages/compiler/__tests__/fixtures/dynamic/fragments/output.js index cb98e99b1..c7b6dec27 100644 --- a/packages/compiler/__tests__/fixtures/dynamic/fragments/output.js +++ b/packages/compiler/__tests__/fixtures/dynamic/fragments/output.js @@ -3,22 +3,24 @@ import { memo as _$memo } from "r-custom"; import { createComponent as _$createComponent } from "r-custom"; import { effect as _$effect } from "r-custom"; import { setAttribute as _$setAttribute } from "r-dom"; -var _tmpl$ = /* @__PURE__ */ _$template(`
First`); -var _tmpl$2 = /* @__PURE__ */ _$template(`
Last`); -var _tmpl$3 = /* @__PURE__ */ _$template(`
`); -var _tmpl$4 = /* @__PURE__ */ _$template(`1`); -var _tmpl$5 = /* @__PURE__ */ _$template(`2`); -var _tmpl$6 = /* @__PURE__ */ _$template(`3`); -const multiStatic = [_tmpl$(), _tmpl$2()]; +var _tmpl$ = /* @__PURE__ */ _$template(`
First
Last
`, 4); +var _tmpl$2 = /* @__PURE__ */ _$template(`
First`); +var _tmpl$3 = /* @__PURE__ */ _$template(`
Last`); +var _tmpl$4 = /* @__PURE__ */ _$template(`
`); +var _tmpl$5 = /* @__PURE__ */ _$template(`1`); +var _tmpl$6 = /* @__PURE__ */ _$template(`2`); +var _tmpl$7 = /* @__PURE__ */ _$template(`3`); +var _tmpl$8 = /* @__PURE__ */ _$template(`123`, 4); +const multiStatic = _tmpl$(); const multiExpression = [ - _tmpl$(), - inserted, _tmpl$2(), + inserted, + _tmpl$3(), "After" ]; const multiDynamic = [ (() => { - var _el$5 = _tmpl$(); + var _el$5 = _tmpl$2(); _$effect(() => state.first, (_v$) => { _$setAttribute(_el$5, "id", _v$); }); @@ -28,7 +30,7 @@ const multiDynamic = [ return state.inserted; }), (() => { - var _el$6 = _tmpl$2(); + var _el$6 = _tmpl$3(); _$effect(() => state.last, (_v$) => { _$setAttribute(_el$6, "id", _v$); }); @@ -42,21 +44,17 @@ const greeting = (x) => "Hello " + x; const singleTemplateLiteral = _$memo(() => { return greeting`world`; }); -const firstStatic = [inserted, _tmpl$3()]; -const firstDynamic = [_$memo(inserted), _tmpl$3()]; -const firstComponent = [_$createComponent(Component, {}), _tmpl$3()]; -const lastStatic = [_tmpl$3(), inserted]; -const lastDynamic = [_tmpl$3(), _$memo(inserted)]; -const lastComponent = [_tmpl$3(), _$createComponent(Component, {})]; +const firstStatic = [inserted, _tmpl$4()]; +const firstDynamic = [_$memo(inserted), _tmpl$4()]; +const firstComponent = [_$createComponent(Component, {}), _tmpl$4()]; +const lastStatic = [_tmpl$4(), inserted]; +const lastDynamic = [_tmpl$4(), _$memo(inserted)]; +const lastComponent = [_tmpl$4(), _$createComponent(Component, {})]; const spaces = [ - _tmpl$4(), - " ", _tmpl$5(), " ", - _tmpl$6() -]; -const multiLineTrailing = [ - _tmpl$4(), - _tmpl$5(), - _tmpl$6() + _tmpl$6(), + " ", + _tmpl$7() ]; +const multiLineTrailing = _tmpl$8(); diff --git a/packages/compiler/src/dom/condition.rs b/packages/compiler/src/dom/condition.rs index b30016d16..b0efa1f30 100644 --- a/packages/compiler/src/dom/condition.rs +++ b/packages/compiler/src/dom/condition.rs @@ -52,6 +52,14 @@ impl<'a> ModeLower<'a> for AstDomTransform<'a, '_> { self.lower_element(element) } + fn lower_static_fragment_run( + &mut self, + children: &[JSXChild<'a>], + start: usize, + ) -> crate::error::Result)>> { + AstDomTransform::lower_static_fragment_run(self, children, start) + } + fn memo_wrap_dynamic_child(&mut self, span: Span, thunk: Expression<'a>) -> Expression<'a> { memo_wrap_thunk(self, span, thunk) } diff --git a/packages/compiler/src/dom/element.rs b/packages/compiler/src/dom/element.rs index a2b4fd522..420a7acda 100644 --- a/packages/compiler/src/dom/element.rs +++ b/packages/compiler/src/dom/element.rs @@ -1,16 +1,20 @@ use crate::error::Result; use oxc_allocator::{Allocator, CloneIn}; use oxc_ast::ast::{ - AssignmentOperator, AssignmentTarget, Expression, JSXElement, JSXExpression, Statement, + AssignmentOperator, AssignmentTarget, Expression, JSXChild, JSXElement, JSXExpression, + Statement, }; use crate::dom::attrs::CloseTagContext; +use crate::dom::static_template::lower_static_native_template; use crate::dom::template::DomTemplateState; +use crate::dom::template::TemplateHtml; use crate::shared::ast_builder::AstBuilder; use crate::shared::bindings::BindingTable; use crate::shared::component::lower_component_with_setup; use crate::shared::utils::{ StaticValue, element_name, is_component_name, is_void_element, static_jsx_expression, + trim_jsx_text, }; pub(crate) struct AstDomTransform<'a, 'source> { @@ -188,6 +192,90 @@ impl<'a, 'source> AstDomTransform<'a, 'source> { } } + /// Combines a run of fully static native roots in a JSX fragment into a + /// single multi-root template clone. Hydration and effectful roots retain + /// their existing per-root lowering so node claiming and side-effect + /// order stay unchanged. + pub(crate) fn lower_static_fragment_run( + &mut self, + children: &[JSXChild<'a>], + start: usize, + ) -> Result)>> { + if self.hydratable { + return Ok(None); + } + + let mut html = String::new(); + let mut closed = String::new(); + let mut import_node = false; + let mut element_count = 0; + let mut consumed = 0; + let mut span = None; + + for child in &children[start..] { + let element = match child { + JSXChild::Text(text) if trim_jsx_text(&text.value).is_empty() => { + consumed += 1; + continue; + } + JSXChild::ExpressionContainer(container) + if matches!(container.expression, JSXExpression::EmptyExpression(_)) => + { + consumed += 1; + continue; + } + JSXChild::Element(element) => element, + _ => break, + }; + if self.is_foreign_element(element) || is_component_name(&element.opening_element.name) + { + break; + } + let tag_name = element_name(&element.opening_element.name)?; + if self.xml_wrapper_tag(element, &tag_name).is_some() { + break; + } + let Some(mut template) = + lower_static_native_template(self, element, CloseTagContext::root())? + else { + break; + }; + + if !is_void_element(&tag_name) { + let close = format!(""); + if !template.html.ends_with(&close) { + template.html.push_str(&close); + } + } + html.push_str(&template.html); + closed.push_str(&template.closed); + import_node = import_node || self.template_subtree_is_import_node(element); + span.get_or_insert(element.span); + element_count += 1; + consumed += 1; + } + + if element_count < 2 { + return Ok(None); + } + + // Babel allocates an element uid while transforming every root even + // when a static fast path later removes it from the output. Consume + // the same ids so following dynamic roots keep compiler parity. + for _ in 0..element_count { + self.next_element_id(); + } + + let span = span.expect("a multi-root run has a first element"); + let flag = 4 | u8::from(import_node); + let template_id = + self.template_id_with_options(TemplateHtml { html, closed }, Some(flag), span); + Ok(Some(( + consumed, + self.template_call(span, Some(&template_id)), + ))) + } + pub(crate) fn lower_element(&mut self, element: &JSXElement<'a>) -> Result> { let (result, setup) = self.lower_element_with_setup(element)?; if setup.is_empty() { @@ -667,4 +755,3 @@ impl AstDomTransform<'_, '_> { crate::shared::classify::Classify::new(&self.bindings, self.source, &self.static_marker) } } - diff --git a/packages/compiler/src/dom/template.rs b/packages/compiler/src/dom/template.rs index 4a0dcaa59..5cd9eac83 100644 --- a/packages/compiler/src/dom/template.rs +++ b/packages/compiler/src/dom/template.rs @@ -54,7 +54,7 @@ pub(crate) struct DomTemplate { /// Babel's `templateWithClosingTags`: the same markup without attributes /// and with every non-void tag closed — the `validate` input. pub(crate) closed_html: String, - /// `template()` second argument: 1 = importNode cloning, 2 = XML-wrapped. + /// `template()` bitmask: 1 = importNode, 2 = XML-wrapped, 4 = multi-root. pub(crate) flag: Option, /// Generated `_tmpl$N` local (collision-checked against source names). pub(crate) name: String, diff --git a/packages/compiler/src/shared/fragment.rs b/packages/compiler/src/shared/fragment.rs index 59b80b7d1..b2f962979 100644 --- a/packages/compiler/src/shared/fragment.rs +++ b/packages/compiler/src/shared/fragment.rs @@ -18,7 +18,16 @@ pub(crate) fn lower_fragment<'a, C: ModeLower<'a>>( let allocator = ctx.condition_allocator(); let ast = mode_ast(ctx); let mut values = std::vec::Vec::new(); - for child in &fragment.children { + let mut index = 0; + while index < fragment.children.len() { + if let Some((consumed, value)) = ctx.lower_static_fragment_run(&fragment.children, index)? { + debug_assert!(consumed >= 2); + values.push(value); + index += consumed; + continue; + } + + let child = &fragment.children[index]; match child { JSXChild::Text(text) => { let value = decode_html_entities(&trim_jsx_text(&text.value)); @@ -28,6 +37,7 @@ pub(crate) fn lower_fragment<'a, C: ModeLower<'a>>( } JSXChild::ExpressionContainer(container) => { if matches!(container.expression, JSXExpression::EmptyExpression(_)) { + index += 1; continue; } // Babel gates fragment-child wrapping on @@ -42,6 +52,7 @@ pub(crate) fn lower_fragment<'a, C: ModeLower<'a>>( .is_dynamic(Some(container.span.start), &expression, false); if !dynamic { values.push(expression); + index += 1; continue; } let thunk = dynamic_child_thunk(ctx, container.span, expression); @@ -60,12 +71,14 @@ pub(crate) fn lower_fragment<'a, C: ModeLower<'a>>( let expression = spread.expression.clone_in(allocator); if !ctx.classify().is_dynamic(None, &expression, false) { values.push(expression); + index += 1; continue; } let thunk = arrow_return_expression(allocator, spread.span, expression); values.push(ctx.memo_wrap_dynamic_child(spread.span, thunk)); } } + index += 1; } Ok(match values.len() { diff --git a/packages/compiler/src/shared/mode_lower.rs b/packages/compiler/src/shared/mode_lower.rs index eebbf81dd..748d9f13c 100644 --- a/packages/compiler/src/shared/mode_lower.rs +++ b/packages/compiler/src/shared/mode_lower.rs @@ -9,7 +9,7 @@ use crate::error::Result; use crate::shared::ast_builder::AstBuilder; -use oxc_ast::ast::{Expression, JSXElement, JSXFragment}; +use oxc_ast::ast::{Expression, JSXChild, JSXElement, JSXFragment}; use oxc_span::Span; use crate::shared::ast::arrow_return_expression; @@ -26,6 +26,16 @@ pub(crate) trait ModeLower<'a>: ConditionBuilder<'a> { /// hydration-keyed `_$ssr` node, universal a setup IIFE. fn lower_child_element(&mut self, element: &JSXElement<'a>) -> Result>; + /// Gives a renderer a chance to lower several adjacent static fragment + /// roots as one value. Non-DOM modes keep the default one-child path. + fn lower_static_fragment_run( + &mut self, + _children: &[JSXChild<'a>], + _start: usize, + ) -> Result)>> { + Ok(None) + } + /// Babel's `createTemplate(wrap: true)` for a dynamic child thunk: /// `memo(thunk)` in the client generates; ssr wraps the accessor body /// with `_$escape` first. diff --git a/packages/compiler/src/universal/transform.rs b/packages/compiler/src/universal/transform.rs index 7d0dd6d96..9bed23384 100644 --- a/packages/compiler/src/universal/transform.rs +++ b/packages/compiler/src/universal/transform.rs @@ -1836,6 +1836,17 @@ impl<'a> crate::shared::mode_lower::ModeLower<'a> for AstUniversalTransform<'a, Ok(self.setup_iife(element.span, setup, value)) } + fn lower_static_fragment_run( + &mut self, + children: &[JSXChild<'a>], + start: usize, + ) -> Result)>> { + let Some(dom) = &mut self.dynamic_dom else { + return Ok(None); + }; + dom.lower_static_fragment_run(children, start) + } + fn memo_wrap_dynamic_child(&mut self, span: Span, thunk: Expression<'a>) -> Expression<'a> { memo_wrap_thunk(self, span, thunk) } diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 9a232bb27..68e41b8cf 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -296,25 +296,40 @@ function create(html, bypassGuard, flag) { ); const t = document.createElement("template"); t.innerHTML = html; - return flag === 2 ? t.content.firstChild.firstChild : t.content.firstChild; + return flag & 4 + ? flag & 2 + ? t.content.firstChild + : t.content + : flag & 2 + ? t.content.firstChild.firstChild + : t.content.firstChild; } /** * Compiler-emitted primitive; not for hand-written code. * @param flag * - `undefined` — clone the template as-is (uses `cloneNode`). * - `1` — use `document.importNode` instead of `cloneNode`. * - `2` — the template html is wrapped; the outer tag is stripped at clone time. + * - `4` — clone a multi-root template and return its child nodes as an array. + * + * Flags are a bitmask, so the compiler may combine them. * @internal */ -export function template(html: string, flag?: 1 | 2): () => Element; +export function template(html: string, flag?: 1 | 2 | 3): () => Element; +export function template(html: string, flag: 4 | 5 | 6 | 7): () => Node[]; export function template(html, flag) { let node; + const getSource = bypassGuard => node || (node = create(html, bypassGuard, flag)); const fn = - flag === 1 - ? bypassGuard => document.importNode(node || (node = create(html, bypassGuard, flag)), true) - : bypassGuard => (node || (node = create(html, bypassGuard, flag))).cloneNode(true); - - if ("_SOLID_DEV_") fn._html = flag === 2 ? html.replace(/^<[^>]+>/, "") : html; + flag & 4 + ? flag & 1 + ? bypassGuard => Array.from(document.importNode(getSource(bypassGuard), true).childNodes) + : bypassGuard => Array.from(getSource(bypassGuard).cloneNode(true).childNodes) + : flag & 1 + ? bypassGuard => document.importNode(getSource(bypassGuard), true) + : bypassGuard => getSource(bypassGuard).cloneNode(true); + + if ("_SOLID_DEV_") fn._html = flag & 2 ? html.replace(/^<[^>]+>/, "") : html; return fn; } /** Compiler-emitted primitive; not for hand-written code. @internal */ export function delegateEvents(eventNames: string[]): void; diff --git a/packages/web/test/multi-root-template.spec.tsx b/packages/web/test/multi-root-template.spec.tsx new file mode 100644 index 000000000..3b535423c --- /dev/null +++ b/packages/web/test/multi-root-template.spec.tsx @@ -0,0 +1,37 @@ +/** @jsxImportSource @solidjs/web */ +import { describe, expect, test } from "vitest"; +import { render } from "../src/client.js"; + +describe("multi-root static fragment templates (#3055)", () => { + test("preserves the fragment array shape and inserts every cloned root", () => { + const children = ( + <> +
first
+ last + + ) as Node[]; + + expect(Array.isArray(children)).toBe(true); + expect(children.map(node => node.nodeName)).toEqual(["DIV", "SPAN"]); + + const container = document.createElement("main"); + const dispose = render(() => children, container); + expect(container.innerHTML).toBe("
first
last"); + dispose(); + }); + + test("preserves namespaces when an SVG root shares the template", () => { + const children = ( + <> + + + +
+ + ) as Node[]; + + expect((children[0] as SVGElement).namespaceURI).toBe("http://www.w3.org/2000/svg"); + expect((children[0].firstChild as SVGElement).namespaceURI).toBe("http://www.w3.org/2000/svg"); + expect((children[1] as HTMLElement).namespaceURI).toBe("http://www.w3.org/1999/xhtml"); + }); +}); diff --git a/packages/web/test/template-document-shell.spec.tsx b/packages/web/test/template-document-shell.spec.tsx index 364cfc090..98aeaa300 100644 --- a/packages/web/test/template-document-shell.spec.tsx +++ b/packages/web/test/template-document-shell.spec.tsx @@ -14,14 +14,15 @@ import { describe, expect, test } from "vitest"; import { template } from "../src/client.js"; describe("client-creating a document shell fails loudly in dev (#3259)", () => { - test.each(["
", "", ""])( - "%s throws at instantiation with a hydrate() pointer", - html => { - const create = template(html); - // compile (registration) is fine — only instantiation is the broken act - expect(create).toThrow(/cannot be client-created[\s\S]*hydrate\(\)/); - } - ); + test.each([ + "
", + "", + "" + ])("%s throws at instantiation with a hydrate() pointer", html => { + const create = template(html); + // compile (registration) is fine — only instantiation is the broken act + expect(create).toThrow(/cannot be client-created[\s\S]*hydrate\(\)/); + }); test("an ordinary template still instantiates", () => { const create = template("
"); @@ -33,4 +34,16 @@ describe("client-creating a document shell fails loudly in dev (#3259)", () => { const create = template("
"); expect((create() as Element).tagName).toBe("HEADER"); }); + + test.each([4, 5] as const)("a multi-root template clones all roots with flag %s", flag => { + const create = template("
first
last", flag); + const first = create(); + const second = create(); + + expect(first.map(node => node.nodeName)).toEqual(["DIV", "SPAN"]); + expect(first.map(node => node.textContent)).toEqual(["first", "last"]); + expect(second.map(node => node.nodeName)).toEqual(["DIV", "SPAN"]); + expect(second[0]).not.toBe(first[0]); + expect(second[1]).not.toBe(first[1]); + }); });