diff --git a/.changeset/yellow-coins-sip.md b/.changeset/yellow-coins-sip.md new file mode 100644 index 0000000000..664429fd06 --- /dev/null +++ b/.changeset/yellow-coins-sip.md @@ -0,0 +1,9 @@ +--- +'@builder.io/mitosis': patch +--- + +[React, Angular] fix: issue with ``state`` inside `key` attribute in `Fragment`. + +Example: + +` jsx > Javascript Test > basicForFragment 1`] = `
+ + " @@ -4608,10 +4622,24 @@ exports[`Alpine.js > jsx > Typescript Test > basicForFragment 1`] = `
+ + " diff --git a/packages/core/src/__tests__/__snapshots__/angular.import.test.ts.snap b/packages/core/src/__tests__/__snapshots__/angular.import.test.ts.snap index 030255a311..6eeb8de867 100644 --- a/packages/core/src/__tests__/__snapshots__/angular.import.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/angular.import.test.ts.snap @@ -3414,6 +3414,20 @@ import { Component } from \\"@angular/core\\";
{{option}}
+ + +
{{option}}
+
+
+ \`, styles: [ @@ -3425,9 +3439,16 @@ import { Component } from \\"@angular/core\\"; ], }) export default class BasicForFragment { + id = \\"xyz\\"; trackByOption0(_, option) { return \`key-\${option}\`; } + trackByOption1(_, option) { + return \`\${this.id}-\${option}\`; + } + trackByOption2(_, option) { + return \`\${this.id}-\${option}\`; + } } @NgModule({ @@ -11081,6 +11102,20 @@ import { Component } from \\"@angular/core\\";
{{option}}
+ + +
{{option}}
+
+
+ \`, styles: [ @@ -11092,9 +11127,16 @@ import { Component } from \\"@angular/core\\"; ], }) export default class BasicForFragment { + id = \\"xyz\\"; trackByOption0(_, option) { return \`key-\${option}\`; } + trackByOption1(_, option) { + return \`\${this.id}-\${option}\`; + } + trackByOption2(_, option) { + return \`\${this.id}-\${option}\`; + } } @NgModule({ diff --git a/packages/core/src/__tests__/__snapshots__/angular.mapper.test.ts.snap b/packages/core/src/__tests__/__snapshots__/angular.mapper.test.ts.snap index 0811f115af..fa0867c783 100644 --- a/packages/core/src/__tests__/__snapshots__/angular.mapper.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/angular.mapper.test.ts.snap @@ -3461,6 +3461,20 @@ import { Component } from \\"@angular/core\\";
{{option}}
+ + +
{{option}}
+
+
+ \`, styles: [ @@ -3472,9 +3486,16 @@ import { Component } from \\"@angular/core\\"; ], }) export default class BasicForFragment { + id = \\"xyz\\"; trackByOption0(_, option) { return \`key-\${option}\`; } + trackByOption1(_, option) { + return \`\${this.id}-\${option}\`; + } + trackByOption2(_, option) { + return \`\${this.id}-\${option}\`; + } } @NgModule({ @@ -11254,6 +11275,20 @@ import { Component } from \\"@angular/core\\";
{{option}}
+ + +
{{option}}
+
+
+ \`, styles: [ @@ -11265,9 +11300,16 @@ import { Component } from \\"@angular/core\\"; ], }) export default class BasicForFragment { + id = \\"xyz\\"; trackByOption0(_, option) { return \`key-\${option}\`; } + trackByOption1(_, option) { + return \`\${this.id}-\${option}\`; + } + trackByOption2(_, option) { + return \`\${this.id}-\${option}\`; + } } @NgModule({ diff --git a/packages/core/src/__tests__/__snapshots__/angular.state.test.ts.snap b/packages/core/src/__tests__/__snapshots__/angular.state.test.ts.snap index 70854852df..70b083603c 100644 --- a/packages/core/src/__tests__/__snapshots__/angular.state.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/angular.state.test.ts.snap @@ -3543,6 +3543,20 @@ import { Component } from \\"@angular/core\\";
{{option}}
+ + +
{{option}}
+
+
+ \`, styles: [ @@ -3554,9 +3568,16 @@ import { Component } from \\"@angular/core\\"; ], }) export default class BasicForFragment { + id = \\"xyz\\"; trackByOption0(_, option) { return \`key-\${option}\`; } + trackByOption1(_, option) { + return \`\${this.id}-\${option}\`; + } + trackByOption2(_, option) { + return \`\${this.id}-\${option}\`; + } } @NgModule({ @@ -11485,6 +11506,20 @@ import { Component } from \\"@angular/core\\";
{{option}}
+ + +
{{option}}
+
+
+ \`, styles: [ @@ -11496,9 +11531,16 @@ import { Component } from \\"@angular/core\\"; ], }) export default class BasicForFragment { + id = \\"xyz\\"; trackByOption0(_, option) { return \`key-\${option}\`; } + trackByOption1(_, option) { + return \`\${this.id}-\${option}\`; + } + trackByOption2(_, option) { + return \`\${this.id}-\${option}\`; + } } @NgModule({ diff --git a/packages/core/src/__tests__/__snapshots__/angular.styles.test.ts.snap b/packages/core/src/__tests__/__snapshots__/angular.styles.test.ts.snap index e074ed773a..b00b71c8cf 100644 --- a/packages/core/src/__tests__/__snapshots__/angular.styles.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/angular.styles.test.ts.snap @@ -3113,13 +3113,34 @@ import { Component } from \\"@angular/core\\";
{{option}}
+ + +
{{option}}
+
+
+ \`, }) export default class BasicForFragment { + id = \\"xyz\\"; trackByOption0(_, option) { return \`key-\${option}\`; } + trackByOption1(_, option) { + return \`\${this.id}-\${option}\`; + } + trackByOption2(_, option) { + return \`\${this.id}-\${option}\`; + } } @NgModule({ @@ -9974,13 +9995,34 @@ import { Component } from \\"@angular/core\\";
{{option}}
+ + +
{{option}}
+
+
+ \`, }) export default class BasicForFragment { + id = \\"xyz\\"; trackByOption0(_, option) { return \`key-\${option}\`; } + trackByOption1(_, option) { + return \`\${this.id}-\${option}\`; + } + trackByOption2(_, option) { + return \`\${this.id}-\${option}\`; + } } @NgModule({ diff --git a/packages/core/src/__tests__/__snapshots__/angular.test.ts.snap b/packages/core/src/__tests__/__snapshots__/angular.test.ts.snap index e40044e65c..daccb367f9 100644 --- a/packages/core/src/__tests__/__snapshots__/angular.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/angular.test.ts.snap @@ -6478,6 +6478,20 @@ import { Component } from \\"@angular/core\\";
{{option}}
+ + +
{{option}}
+
+
+ \`, styles: [ @@ -6489,9 +6503,16 @@ import { Component } from \\"@angular/core\\"; ], }) export default class BasicForFragment { + id = \\"xyz\\"; trackByOption0(_, option) { return \`key-\${option}\`; } + trackByOption1(_, option) { + return \`\${this.id}-\${option}\`; + } + trackByOption2(_, option) { + return \`\${this.id}-\${option}\`; + } } @NgModule({ @@ -6518,6 +6539,20 @@ import { CommonModule } from \\"@angular/common\\";
{{option}}
+ + +
{{option}}
+
+
+ \`, styles: [ @@ -6531,9 +6566,16 @@ import { CommonModule } from \\"@angular/common\\"; imports: [CommonModule], }) export default class BasicForFragment { + id = \\"xyz\\"; trackByOption0(_, option) { return \`key-\${option}\`; } + trackByOption1(_, option) { + return \`\${this.id}-\${option}\`; + } + trackByOption2(_, option) { + return \`\${this.id}-\${option}\`; + } } " `; @@ -20898,6 +20940,20 @@ import { Component } from \\"@angular/core\\";
{{option}}
+ + +
{{option}}
+
+
+ \`, styles: [ @@ -20909,9 +20965,16 @@ import { Component } from \\"@angular/core\\"; ], }) export default class BasicForFragment { + id = \\"xyz\\"; trackByOption0(_, option) { return \`key-\${option}\`; } + trackByOption1(_, option) { + return \`\${this.id}-\${option}\`; + } + trackByOption2(_, option) { + return \`\${this.id}-\${option}\`; + } } @NgModule({ @@ -20938,6 +21001,20 @@ import { CommonModule } from \\"@angular/common\\";
{{option}}
+ + +
{{option}}
+
+
+ \`, styles: [ @@ -20951,9 +21028,16 @@ import { CommonModule } from \\"@angular/common\\"; imports: [CommonModule], }) export default class BasicForFragment { + id = \\"xyz\\"; trackByOption0(_, option) { return \`key-\${option}\`; } + trackByOption1(_, option) { + return \`\${this.id}-\${option}\`; + } + trackByOption2(_, option) { + return \`\${this.id}-\${option}\`; + } } " `; diff --git a/packages/core/src/__tests__/__snapshots__/html.test.ts.snap b/packages/core/src/__tests__/__snapshots__/html.test.ts.snap index 0f0e3aee95..754830f4af 100644 --- a/packages/core/src/__tests__/__snapshots__/html.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/html.test.ts.snap @@ -3781,10 +3781,23 @@ exports[`Html > jsx > Javascript Test > basicForFragment 1`] = ` + + + + +
{#each [\\"a\\", \\"b\\", \\"c\\"] as option (\`key-\${option}\`)}
{option}
{/each} + + {#each [\\"a\\", \\"b\\", \\"c\\"] as option (\`\${id}-\${option}\`)} +
{option}
+ {/each} +
" `; @@ -4981,12 +4994,22 @@ exports[`Svelte > jsx > Typescript Test > arrowFunctionInUseStore 1`] = ` exports[`Svelte > jsx > Typescript Test > basicForFragment 1`] = ` "
{#each [\\"a\\", \\"b\\", \\"c\\"] as option (\`key-\${option}\`)}
{option}
{/each} + + {#each [\\"a\\", \\"b\\", \\"c\\"] as option (\`\${id}-\${option}\`)} +
{option}
+ {/each} +
" `; diff --git a/packages/core/src/__tests__/__snapshots__/taro.test.ts.snap b/packages/core/src/__tests__/__snapshots__/taro.test.ts.snap index f3464bbee9..532b08e5ce 100644 --- a/packages/core/src/__tests__/__snapshots__/taro.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/taro.test.ts.snap @@ -1681,10 +1681,14 @@ export default MyComponent; `; exports[`Taro > jsx > Javascript Test > basicForFragment 1`] = ` -"import * as React from \\"react\\"; +"\\"use client\\"; +import * as React from \\"react\\"; +import { useState } from \\"react\\"; import { View, Text } from \\"@tarojs/components\\"; function BasicForFragment(props) { + const [id, setId] = useState(() => \\"xyz\\"); + return ( {[\\"a\\", \\"b\\", \\"c\\"]?.map((option) => ( @@ -1692,6 +1696,18 @@ function BasicForFragment(props) { {option} ))} + {[\\"a\\", \\"b\\", \\"c\\"]?.map((option) => ( + + {option} + + ))} + + {[\\"d\\", \\"e\\", \\"f\\"]?.map((option) => ( + + {option} + + ))} + ); } @@ -5618,10 +5634,14 @@ export default MyComponent; `; exports[`Taro > jsx > Typescript Test > basicForFragment 1`] = ` -"import * as React from \\"react\\"; +"\\"use client\\"; +import * as React from \\"react\\"; +import { useState } from \\"react\\"; import { View, Text } from \\"@tarojs/components\\"; function BasicForFragment(props: any) { + const [id, setId] = useState(() => \\"xyz\\"); + return ( {[\\"a\\", \\"b\\", \\"c\\"]?.map((option) => ( @@ -5629,6 +5649,18 @@ function BasicForFragment(props: any) { {option} ))} + {[\\"a\\", \\"b\\", \\"c\\"]?.map((option) => ( + + {option} + + ))} + + {[\\"d\\", \\"e\\", \\"f\\"]?.map((option) => ( + + {option} + + ))} + ); } diff --git a/packages/core/src/__tests__/__snapshots__/vue-composition.test.ts.snap b/packages/core/src/__tests__/__snapshots__/vue-composition.test.ts.snap index dc06581a78..e25007a4b0 100644 --- a/packages/core/src/__tests__/__snapshots__/vue-composition.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/vue-composition.test.ts.snap @@ -1673,10 +1673,28 @@ exports[`Vue > jsx > Javascript Test > basicForFragment 1`] = ` " -" + + +" `; exports[`Vue > jsx > Javascript Test > basicForNoTagReference 1`] = ` @@ -5218,10 +5236,28 @@ exports[`Vue > jsx > Typescript Test > basicForFragment 1`] = ` " -" + + +" `; exports[`Vue > jsx > Typescript Test > basicForNoTagReference 1`] = ` diff --git a/packages/core/src/__tests__/__snapshots__/vue.test.ts.snap b/packages/core/src/__tests__/__snapshots__/vue.test.ts.snap index f7f020af64..14dd49c49a 100644 --- a/packages/core/src/__tests__/__snapshots__/vue.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/vue.test.ts.snap @@ -1966,8 +1966,20 @@ exports[`Vue > jsx > Javascript Test > basicForFragment 1`] = ` " @@ -1976,6 +1988,10 @@ import { defineComponent } from \\"vue\\"; export default defineComponent({ name: \\"basic-for-fragment\\", + + data() { + return { id: \\"xyz\\" }; + }, }); " `; @@ -6422,8 +6438,20 @@ exports[`Vue > jsx > Typescript Test > basicForFragment 1`] = ` " @@ -6432,6 +6460,10 @@ import { defineComponent } from \\"vue\\"; export default defineComponent({ name: \\"basic-for-fragment\\", + + data() { + return { id: \\"xyz\\" }; + }, }); " `; diff --git a/packages/core/src/__tests__/__snapshots__/webcomponent.test.ts.snap b/packages/core/src/__tests__/__snapshots__/webcomponent.test.ts.snap index df5c975635..19d0e8a5d7 100644 --- a/packages/core/src/__tests__/__snapshots__/webcomponent.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/webcomponent.test.ts.snap @@ -7700,7 +7700,7 @@ class BasicForFragment extends HTMLElement { super(); const self = this; - this.state = {}; + this.state = { id: \\"xyz\\" }; if (!this.props) { this.props = {}; } @@ -7729,6 +7729,19 @@ class BasicForFragment extends HTMLElement { + + + \`; this.pendingUpdate = true; @@ -7753,9 +7766,46 @@ class BasicForFragment extends HTMLElement { } render() { + // grab previous input state + const preStateful = this.getStateful(this._root); + const preValues = this.prepareHydrate(preStateful); + // re-rendering needs to ensure that all nodes generated by for/show are refreshed this.destroyAnyNodes(); this.updateBindings(); + + // hydrate input state + if (preValues.length) { + const nextStateful = this.getStateful(this._root); + this.hydrateDom(preValues, nextStateful); + } + } + + getStateful(el) { + const stateful = el.querySelectorAll(\\"[data-dom-state]\\"); + return stateful ? Array.from(stateful) : []; + } + prepareHydrate(stateful) { + return stateful.map((el) => { + return { + id: el.dataset.domState, + value: el.value, + active: document.activeElement === el, + selectionStart: el.selectionStart, + }; + }); + } + hydrateDom(preValues, stateful) { + return stateful.map((el, index) => { + const prev = preValues.find((prev) => el.dataset.domState === prev.id); + if (prev) { + el.value = prev.value; + if (prev.active) { + el.focus(); + el.selectionStart = prev.selectionStart; + } + } + }); } updateBindings() { @@ -7772,6 +7822,42 @@ class BasicForFragment extends HTMLElement { const option = this.getScope(el, \\"option\\"); this.renderTextNode(el, option); }); + + this._root + .querySelectorAll(\\"[data-el='for-basic-for-fragment-2']\\") + .forEach((el) => { + let array = [\\"a\\", \\"b\\", \\"c\\"]; + this.renderLoop(el, array, \\"option\\"); + }); + + this._root + .querySelectorAll(\\"[data-el='div-basic-for-fragment-2']\\") + .forEach((el) => { + const option = this.getScope(el, \\"option\\"); + this.renderTextNode(el, option); + }); + + this._root + .querySelectorAll(\\"[data-el='for-basic-for-fragment-3']\\") + .forEach((el) => { + let array = [\\"d\\", \\"e\\", \\"f\\"]; + this.renderLoop(el, array, \\"option\\"); + }); + + this._root + .querySelectorAll(\\"[data-el='option-basic-for-fragment-1']\\") + .forEach((el) => { + const option = this.getScope(el, \\"option\\"); + el.key = \`\${this.state.id}-\${option}\`; + el.value = option; + }); + + this._root + .querySelectorAll(\\"[data-el='div-basic-for-fragment-3']\\") + .forEach((el) => { + const option = this.getScope(el, \\"option\\"); + this.renderTextNode(el, option); + }); } // Helper to render content @@ -24374,7 +24460,7 @@ class BasicForFragment extends HTMLElement { super(); const self = this; - this.state = {}; + this.state = { id: \\"xyz\\" }; if (!this.props) { this.props = {}; } @@ -24403,6 +24489,19 @@ class BasicForFragment extends HTMLElement { + + + \`; this.pendingUpdate = true; @@ -24427,9 +24526,46 @@ class BasicForFragment extends HTMLElement { } render() { + // grab previous input state + const preStateful = this.getStateful(this._root); + const preValues = this.prepareHydrate(preStateful); + // re-rendering needs to ensure that all nodes generated by for/show are refreshed this.destroyAnyNodes(); this.updateBindings(); + + // hydrate input state + if (preValues.length) { + const nextStateful = this.getStateful(this._root); + this.hydrateDom(preValues, nextStateful); + } + } + + getStateful(el) { + const stateful = el.querySelectorAll(\\"[data-dom-state]\\"); + return stateful ? Array.from(stateful) : []; + } + prepareHydrate(stateful) { + return stateful.map((el) => { + return { + id: el.dataset.domState, + value: el.value, + active: document.activeElement === el, + selectionStart: el.selectionStart, + }; + }); + } + hydrateDom(preValues, stateful) { + return stateful.map((el, index) => { + const prev = preValues.find((prev) => el.dataset.domState === prev.id); + if (prev) { + el.value = prev.value; + if (prev.active) { + el.focus(); + el.selectionStart = prev.selectionStart; + } + } + }); } updateBindings() { @@ -24446,6 +24582,42 @@ class BasicForFragment extends HTMLElement { const option = this.getScope(el, \\"option\\"); this.renderTextNode(el, option); }); + + this._root + .querySelectorAll(\\"[data-el='for-basic-for-fragment-2']\\") + .forEach((el) => { + let array = [\\"a\\", \\"b\\", \\"c\\"]; + this.renderLoop(el, array, \\"option\\"); + }); + + this._root + .querySelectorAll(\\"[data-el='div-basic-for-fragment-2']\\") + .forEach((el) => { + const option = this.getScope(el, \\"option\\"); + this.renderTextNode(el, option); + }); + + this._root + .querySelectorAll(\\"[data-el='for-basic-for-fragment-3']\\") + .forEach((el) => { + let array = [\\"d\\", \\"e\\", \\"f\\"]; + this.renderLoop(el, array, \\"option\\"); + }); + + this._root + .querySelectorAll(\\"[data-el='option-basic-for-fragment-1']\\") + .forEach((el) => { + const option = this.getScope(el, \\"option\\"); + el.key = \`\${this.state.id}-\${option}\`; + el.value = option; + }); + + this._root + .querySelectorAll(\\"[data-el='div-basic-for-fragment-3']\\") + .forEach((el) => { + const option = this.getScope(el, \\"option\\"); + this.renderTextNode(el, option); + }); } // Helper to render content diff --git a/packages/core/src/__tests__/data/for/basic-for-fragment.raw.tsx b/packages/core/src/__tests__/data/for/basic-for-fragment.raw.tsx index 13053674f0..a49f1e4f27 100644 --- a/packages/core/src/__tests__/data/for/basic-for-fragment.raw.tsx +++ b/packages/core/src/__tests__/data/for/basic-for-fragment.raw.tsx @@ -1,6 +1,10 @@ -import { For, Fragment } from '@builder.io/mitosis'; +import { For, Fragment, useStore } from '@builder.io/mitosis'; export default function BasicForFragment() { + const state = useStore({ + id: 'xyz', + }); + return (
@@ -10,6 +14,22 @@ export default function BasicForFragment() { )} + + {(option) => ( + +
{option}
+
+ )} +
+
); } diff --git a/packages/core/src/generators/angular/helpers.ts b/packages/core/src/generators/angular/helpers.ts index dd602f5071..7a9776a76a 100644 --- a/packages/core/src/generators/angular/helpers.ts +++ b/packages/core/src/generators/angular/helpers.ts @@ -1,5 +1,6 @@ import { stripStateAndPropsRefs } from '@/helpers/strip-state-and-props-refs'; import { type MitosisComponent } from '@/types/mitosis-component'; +import { MitosisNode } from '@/types/mitosis-node'; export const HELPER_FUNCTIONS = ( isTs?: boolean, @@ -121,3 +122,16 @@ export const transformState = (json: MitosisComponent) => { } }); }; + +/** + * Checks if the first child has a "key" attribute - used for "For" elements + * @param node The node which should be "For" + */ +export const hasFirstChildKeyAttribute = (node: MitosisNode): boolean => { + if (!node.children || node.children.length === 0) { + return false; + } + + const firstChildBinding = node.children[0].bindings; + return Boolean(firstChildBinding && firstChildBinding.key?.code); +}; diff --git a/packages/core/src/generators/angular/index.ts b/packages/core/src/generators/angular/index.ts index c7c35ae389..b31710901d 100644 --- a/packages/core/src/generators/angular/index.ts +++ b/packages/core/src/generators/angular/index.ts @@ -52,6 +52,7 @@ import { addCodeToOnUpdate, getAppropriateTemplateFunctionKeys, getDefaultProps, + hasFirstChildKeyAttribute, makeReactiveState, transformState, } from './helpers'; @@ -351,7 +352,7 @@ export const blockToAngular = ({ const forName = json.scope.forName; // Check if "key" is present for the first child of the for loop - if (json.children[0].bindings && json.children[0].bindings.key?.code) { + if (hasFirstChildKeyAttribute(json)) { const fnIndex = (root.meta?._trackByForIndex as number) || 0; const trackByFnName = `trackBy${ forName ? forName.charAt(0).toUpperCase() + forName.slice(1) : '' @@ -835,11 +836,15 @@ export const componentToAngular: TranspilerGenerator = node?.bindings[key]?.type === 'spread' && VALID_HTML_TAGS.includes(node.name.trim()); + // If we have a For loop with "key" it will be transformed to + // trackOfXXX, we need to use "this" for state properties + const isKey = key === 'key'; + const newLocal = processAngularCode({ contextVars: [], outputVars, domRefs: [], // the template doesn't need the this keyword. - replaceWith: isSpreadAttributeBinding ? 'this' : undefined, + replaceWith: isKey || isSpreadAttributeBinding ? 'this' : undefined, })(code); return newLocal.replace(/"/g, '"'); }; diff --git a/packages/core/src/generators/react/helpers.ts b/packages/core/src/generators/react/helpers.ts index 30f20ac2dd..b4ba8e9292 100644 --- a/packages/core/src/generators/react/helpers.ts +++ b/packages/core/src/generators/react/helpers.ts @@ -27,8 +27,9 @@ export function getFragment(type: 'open' | 'close', options: ToReactOptions, nod let tag = ''; if (node && node.bindings && isFragmentWithKey(node)) { tag = options.preact ? 'Fragment' : 'React.Fragment'; - if (type === 'open') { - tag += ` key={${node.bindings['key']?.code}}`; + const keyCode = node.bindings['key']?.code; + if (type === 'open' && keyCode) { + tag += ` key={${processBinding(keyCode, options)}}`; } } else if (options.preact) { tag = 'Fragment';