Skip to content

Commit 64a732b

Browse files
kraus-milandashersw
authored andcommitted
fix(vite-plugin): fix state observer for text nodes with mixed state+prop dependencies
1 parent 9d72c1b commit 64a732b

7 files changed

Lines changed: 189 additions & 15 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@geajs/core": patch
3+
"@geajs/vite-plugin": patch
4+
---
5+
6+
Fix state observer for text nodes with mixed state+prop dependencies: When a text node expression referenced both reactive state (e.g. `this.valueAsString`) and props (e.g. `props.placeholder`), the compiler reused the prop-change handler's expression for the state observer. In that expression `value` represented the new prop value — but in the state observer `value` is the new state value, causing props to read as empty. The compiler now generates a separate stateOnly binding for the state observer, so `this.props.X` is read live instead of being replaced with `value`.

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/vite-plugin-gea/src/analyze.ts

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
detectContainerSelector,
4141
hasExplicitItemKey,
4242
} from './analyze-helpers.ts'
43-
import { collectExpressionDependencies, collectTemplateSetupStatements } from './transform-attributes.ts'
43+
import { collectExpressionDependencies, collectTemplateSetupStatements, isStateDep } from './transform-attributes.ts'
4444
import type { StateRefMeta } from './parse.ts'
4545
import { createRequire } from 'module'
4646

@@ -684,9 +684,7 @@ function analyzeAttributes(
684684
if (templateSetupContext && !t.isJSXEmptyExpression(expr)) {
685685
const setupStatements = collectTemplateSetupStatements(expr, templateSetupContext)
686686
const dependencies = collectExpressionDependencies(expr, stateRefs, setupStatements)
687-
const stateDeps = dependencies.filter(
688-
(d) => d.storeVar || (d.pathParts.length > 0 && d.pathParts[0] !== 'props'),
689-
)
687+
const stateDeps = dependencies.filter(isStateDep)
690688
if (stateDeps.length > 0) {
691689
const selector = generateSelector(elementPath)
692690
propBindings.push({
@@ -748,7 +746,7 @@ function analyzeAttributes(
748746
if (isNativeElement && templateSetupContext && !t.isJSXEmptyExpression(expr)) {
749747
const setupStatements = collectTemplateSetupStatements(expr, templateSetupContext)
750748
const dependencies = collectExpressionDependencies(expr, stateRefs, setupStatements)
751-
const stateDeps = dependencies.filter((d) => d.storeVar || (d.pathParts.length > 0 && d.pathParts[0] !== 'props'))
749+
const stateDeps = dependencies.filter(isStateDep)
752750
if (stateDeps.length > 0) {
753751
const selector = generateSelector(elementPath)
754752
propBindings.push({
@@ -1150,7 +1148,7 @@ function handleArrayMap(
11501148
const locStr = loc ? ` (line ${loc.line}, col ${loc.column})` : ''
11511149
const err = new Error(
11521150
`[gea] Array .map() items must have a \`key\` prop on the root element${locStr}. ` +
1153-
`Add key={item.id} (or another unique identifier) to the outermost JSX element returned by the .map() callback.`,
1151+
`Add key={item.id} (or another unique identifier) to the outermost JSX element returned by the .map() callback.`,
11541152
)
11551153
;(err as any).__geaCompileError = true
11561154
throw err
@@ -1462,6 +1460,21 @@ function handleTextBinding(
14621460
...(textNodeIndex !== undefined ? { textNodeIndex } : {}),
14631461
})),
14641462
)
1463+
const templateStateDeps = collectExpressionDependencies(derivedTemplateExpr, stateRefs, setupStatements)
1464+
.filter(isStateDep)
1465+
if (templateStateDeps.length > 0) {
1466+
const stateOnlyBinding: PropBinding = {
1467+
propName: '__state__',
1468+
selector,
1469+
type: 'text',
1470+
elementPath: [...elementPath],
1471+
expression: t.cloneNode(derivedTemplateExpr, true) as t.Expression,
1472+
setupStatements: setupStatements.map((s) => t.cloneNode(s, true) as t.Statement),
1473+
stateOnly: true,
1474+
}
1475+
if (textNodeIndex !== undefined) stateOnlyBinding.textNodeIndex = textNodeIndex
1476+
propBindings.push(stateOnlyBinding)
1477+
}
14651478
return
14661479
}
14671480
}
@@ -1480,6 +1493,25 @@ function handleTextBinding(
14801493
for (const d of derived) d.textNodeIndex = textNodeIndex
14811494
}
14821495
propBindings.push(...derived)
1496+
// When the expression also has state deps, add a stateOnly binding so the state
1497+
// observer reads this.props.X live rather than inheriting `value` from the prop
1498+
// binding's patch body (where `value` is the incoming prop value, not the new state).
1499+
const setupStatements = derived[0].setupStatements
1500+
const stateDeps = collectExpressionDependencies(expr as t.Expression, stateRefs, setupStatements ?? [])
1501+
.filter(isStateDep)
1502+
if (stateDeps.length > 0) {
1503+
const stateOnlyBinding: PropBinding = {
1504+
propName: '__state__',
1505+
selector: generateSelector(elementPath),
1506+
type: 'text',
1507+
elementPath: [...elementPath],
1508+
expression: t.cloneNode(expr, true) as t.Expression,
1509+
setupStatements,
1510+
stateOnly: true,
1511+
}
1512+
if (textNodeIndex !== undefined) stateOnlyBinding.textNodeIndex = textNodeIndex
1513+
propBindings.push(stateOnlyBinding)
1514+
}
14831515
return
14841516
}
14851517
}
@@ -1546,7 +1578,7 @@ function handleTextBinding(
15461578
: expr
15471579
const setupStatements = collectTemplateSetupStatements(exprToUse, templateSetupContext)
15481580
const dependencies = collectExpressionDependencies(exprToUse, stateRefs, setupStatements)
1549-
const stateDeps = dependencies.filter((d) => d.storeVar || (d.pathParts.length > 0 && d.pathParts[0] !== 'props'))
1581+
const stateDeps = dependencies.filter(isStateDep)
15501582
if (stateDeps.length > 0) {
15511583
const selector = generateSelector(elementPath)
15521584
propBindings.push({
@@ -1839,13 +1871,13 @@ function collectAllStateAccesses(
18391871
const setupStatements =
18401872
rootExpr && templateSetupContext
18411873
? (() => {
1842-
const collected = collectTemplateSetupStatements(rootExpr, templateSetupContext)
1843-
if (collected.length > 0) return collected.map((s) => t.cloneNode(s, true) as t.Statement)
1844-
if (templateSetupContext.earlyReturnBarrierIndex === undefined) return []
1845-
return templateSetupContext.statements
1846-
.slice(0, templateSetupContext.earlyReturnBarrierIndex + 1)
1847-
.map((s) => t.cloneNode(s, true) as t.Statement)
1848-
})()
1874+
const collected = collectTemplateSetupStatements(rootExpr, templateSetupContext)
1875+
if (collected.length > 0) return collected.map((s) => t.cloneNode(s, true) as t.Statement)
1876+
if (templateSetupContext.earlyReturnBarrierIndex === undefined) return []
1877+
return templateSetupContext.statements
1878+
.slice(0, templateSetupContext.earlyReturnBarrierIndex + 1)
1879+
.map((s) => t.cloneNode(s, true) as t.Statement)
1880+
})()
18491881
: []
18501882
const prog = t.program([...setupStatements, t.expressionStatement(expr)])
18511883

packages/vite-plugin-gea/src/ir.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ export interface PropBinding {
137137
userIdExpr?: t.Expression
138138
/** When true, the binding depends solely on local/imported state, not on props */
139139
stateOnly?: boolean
140+
/** Index of the text node within its parent when the binding targets a specific text node */
141+
textNodeIndex?: number
140142
}
141143

142144
export interface ConditionalSlot {

packages/vite-plugin-gea/src/transform-attributes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ export function collectExpressionDependencies(
8989
return Array.from(dependencies.values())
9090
}
9191

92+
/** Returns true when a dependency is reactive state (not a prop dependency). */
93+
export const isStateDep = (d: ObserveDependency): boolean =>
94+
d.storeVar !== undefined || (d.pathParts.length > 0 && d.pathParts[0] !== 'props')
95+
9296
function collectExpressionDependenciesInto(
9397
expr: t.Expression,
9498
stateRefs: Map<string, StateRefMeta> | undefined,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Regression: text node with mixed state+prop deps must generate a stateOnly binding
3+
* so the state observer reads this.props.X live instead of inheriting `value` from
4+
* the prop binding's patch body (where `value` is the incoming prop value).
5+
*
6+
* Reproduces the Select placeholder bug: `{this.valueAsString || props.placeholder || 'Select...'}`
7+
* showed 'Select...' after the Zag machine started (setting valueAsString="") because the state
8+
* observer for valueAsString received value="" and the compiled patch body substituted that for
9+
* props.placeholder. Initial HTML rendered correctly; the bug fired on the post-render state flush.
10+
*/
11+
12+
import assert from 'node:assert/strict'
13+
import { describe, it } from 'node:test'
14+
import { installDom, flushMicrotasks } from '../../../../tests/helpers/jsdom-setup'
15+
import { compileJsxComponent, loadRuntimeModules } from '../helpers/compile'
16+
17+
const SOURCE = `
18+
import { Component } from '@geajs/core'
19+
20+
export default class SelectLike extends Component {
21+
declare valueAsString: string
22+
23+
onAfterRender(): void {
24+
this.valueAsString = ''
25+
}
26+
27+
template(props: any) {
28+
return (
29+
<div>
30+
<span class="display">
31+
{this.valueAsString || props.placeholder || 'Select...'}
32+
</span>
33+
</div>
34+
)
35+
}
36+
}
37+
`
38+
39+
describe('text mixed state+prop regression', () => {
40+
it('prop fallback shown on initial render when state is empty', async () => {
41+
const restoreDom = installDom()
42+
try {
43+
const seed = `text-state-prop-${Date.now()}`
44+
const [{ default: Component }] = await loadRuntimeModules(seed)
45+
const SelectLike = await compileJsxComponent(SOURCE, '/virtual/SelectLike.jsx', 'SelectLike', { Component })
46+
47+
const root = document.createElement('div')
48+
document.body.appendChild(root)
49+
50+
const comp = new SelectLike({ placeholder: 'Pick one...' })
51+
comp.render(root)
52+
await flushMicrotasks()
53+
54+
assert.equal(
55+
comp.el.querySelector('.display')?.textContent?.trim(),
56+
'Pick one...',
57+
'placeholder must be shown when valueAsString is empty',
58+
)
59+
60+
comp.valueAsString = 'Option A'
61+
await flushMicrotasks()
62+
assert.equal(comp.el.querySelector('.display')?.textContent?.trim(), 'Option A', 'selected value must appear')
63+
64+
comp.valueAsString = ''
65+
await flushMicrotasks()
66+
assert.equal(
67+
comp.el.querySelector('.display')?.textContent?.trim(),
68+
'Pick one...',
69+
'placeholder must return when state is cleared',
70+
)
71+
72+
comp.dispose()
73+
} finally {
74+
restoreDom()
75+
}
76+
})
77+
78+
it('prop change updates text using current state value', async () => {
79+
const restoreDom = installDom()
80+
try {
81+
const seed = `text-state-prop-propchange-${Date.now()}`
82+
const [{ default: Component }, { Store }] = await loadRuntimeModules(seed)
83+
const store = new Store({ placeholder: 'First...' })
84+
85+
const SelectLike = await compileJsxComponent(SOURCE, '/virtual/SelectLike.jsx', 'SelectLike', { Component })
86+
87+
const Parent = await compileJsxComponent(
88+
`
89+
import { Component } from '@geajs/core'
90+
import store from './store'
91+
import SelectLike from './SelectLike'
92+
export default class Parent extends Component {
93+
template() {
94+
return <SelectLike placeholder={store.placeholder} />
95+
}
96+
}
97+
`,
98+
'/virtual/Parent.jsx',
99+
'Parent',
100+
{ Component, store, SelectLike },
101+
)
102+
103+
const root = document.createElement('div')
104+
document.body.appendChild(root)
105+
106+
const parent = new Parent()
107+
parent.render(root)
108+
await flushMicrotasks()
109+
110+
assert.equal(parent.el.querySelector('.display')?.textContent?.trim(), 'First...')
111+
112+
store.placeholder = 'Second...'
113+
await flushMicrotasks()
114+
assert.equal(
115+
parent.el.querySelector('.display')?.textContent?.trim(),
116+
'Second...',
117+
'prop change must update text when state is empty',
118+
)
119+
120+
parent.dispose()
121+
} finally {
122+
restoreDom()
123+
}
124+
})
125+
})

tests/e2e/showcase.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ test.describe('showcase component gallery', () => {
7474
await expect(page.locator('[data-scope="select"]').first()).toBeVisible()
7575
})
7676

77+
test('select component uses placeholder passed in props', async ({ page }) => {
78+
const select = page.locator('[data-scope="select"]').first()
79+
await expect(select.locator('[data-part="value-text"]')).toHaveText('Pick one...')
80+
})
81+
7782
test('switch toggles are visible', async ({ page }) => {
7883
const switches = page.locator('[data-scope="switch"]')
7984
const count = await switches.count()

0 commit comments

Comments
 (0)