Skip to content

Commit 8697761

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix: trace rendered React state updates in design diff
1 parent ed4c9f5 commit 8697761

4 files changed

Lines changed: 192 additions & 5 deletions

File tree

‎scripts/design-diff/README.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ scripts/design-diff/
107107
ast.ts Babel parsing and syntax normalization
108108
refactors.ts Supported literal/refactor normalization
109109
mutations.ts Referenced collection/property writes
110+
state.ts React state inputs and setter assignments
110111
finite.ts Static finite keys for computed environment reads
111112
environment.ts Literal createEnv schema field comparison
112113
resolve.ts Bounded expression and import resolution
@@ -158,8 +159,12 @@ TSX/CSS files that an application build or Tailwind source scan could consume.
158159
Babel parses JS/TS/JSX; PostCSS parses CSS; parse5 parses HTML; remark parses
159160
Markdown/MDX/frontmatter/GFM. CSS selector, conditional and declaration order are retained.
160161
JSX whitespace follows React's line handling. Class composition and JSX spread/attribute
161-
order remain significant. Direct event handlers are not treated as appearance props; this
162-
does not establish equivalence of arbitrary interactive behavior.
162+
order remain significant. Direct event handlers are not treated as appearance props. For
163+
recognized React `useState` bindings, a rendered state value also traces setter arguments,
164+
local setter aliases and surrounding conditions, including updates inside event handlers.
165+
Unrelated handler statements and unrendered state do not become visual inputs. Setter escapes
166+
remain unresolved; arbitrary event reachability, reducer/effect scheduling and external store
167+
updates are not fully modeled.
163168

164169
The resolver supports immutable constants, object properties, arrays, primitive template
165170
strings, simple arithmetic, conditional branches, static imports/re-exports, namespace

‎scripts/design-diff/resolve.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { mutations } from '#design-diff/mutations'
1414
import { previewValue } from '#design-diff/report'
1515
import type { SemanticValues } from '#design-diff/semantic'
1616
import type { SourceTree } from '#design-diff/source'
17+
import { stateInputs } from '#design-diff/state'
1718
import type { Data, Evidence } from '#design-diff/types'
1819

1920
export function child(path: NodePath, name: string): NodePath {
@@ -304,8 +305,9 @@ export class Resolver {
304305
}
305306

306307
/** Conditions around writes can change the visible collection even when each value is unchanged. */
307-
private mutationValue(write: NodePath, file: string, depth: number): Data {
308+
private mutationValue(write: NodePath, file: string, depth: number, input = write): Data {
308309
const conditions: Data[] = []
310+
let branch = write
309311
for (
310312
let parent = write.parentPath;
311313
parent && !parent.isFunction();
@@ -318,11 +320,15 @@ export class Resolver {
318320
parent.isDoWhileStatement() ||
319321
parent.isForStatement()
320322
)
321-
conditions.push(this.value(child(parent, 'test'), file, depth + 1))
323+
conditions.push({
324+
test: this.value(child(parent, 'test'), file, depth + 1),
325+
branch: branch.key ?? null,
326+
})
322327
if (parent.isForOfStatement() || parent.isForInStatement())
323328
conditions.push(this.value(child(parent, 'right'), file, depth + 1))
329+
branch = parent
324330
}
325-
return { value: this.value(write, file, depth + 1), conditions }
331+
return { value: this.value(input, file, depth + 1), conditions }
326332
}
327333

328334
private currentFile = ''
@@ -555,6 +561,20 @@ export class Resolver {
555561
file: string,
556562
depth: number
557563
): Data | undefined {
564+
const state = stateInputs(binding, name)
565+
if (state) {
566+
this.unresolved.add('React state updates feed rendering; event reachability is not executed')
567+
return {
568+
$reactState: state.initial ? this.value(state.initial, file, depth + 1) : null,
569+
updates: state.writes.map((write) => {
570+
const argument = children(write, 'arguments')[0]
571+
return argument ? this.mutationValue(write, file, depth + 1, argument) : null
572+
}),
573+
escapes: state.escapes.map((escaped) =>
574+
this.unknown(escaped, 'React state setter escapes static call resolution')
575+
),
576+
}
577+
}
558578
const find = (
559579
pattern: NodePath,
560580
keys: Data[]

‎scripts/design-diff/state.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { Binding, NodePath } from '@babel/traverse'
2+
import * as t from '@babel/types'
3+
4+
/** Recognize React's binding, including import aliases, without trusting a local hook name. */
5+
function reactStateHook(callee: NodePath): boolean {
6+
if (callee.isIdentifier()) {
7+
const binding = callee.scope.getBinding(callee.node.name)?.path
8+
return !!(
9+
binding?.isImportSpecifier() &&
10+
t.isIdentifier(binding.node.imported, { name: 'useState' }) &&
11+
binding.parentPath.isImportDeclaration() &&
12+
binding.parentPath.node.source.value === 'react'
13+
)
14+
}
15+
if (
16+
callee.isMemberExpression() &&
17+
!callee.node.computed &&
18+
t.isIdentifier(callee.node.property, { name: 'useState' }) &&
19+
t.isIdentifier(callee.node.object)
20+
) {
21+
const binding = callee.scope.getBinding(callee.node.object.name)?.path
22+
return !!(
23+
(binding?.isImportDefaultSpecifier() || binding?.isImportNamespaceSpecifier()) &&
24+
binding.parentPath.isImportDeclaration() &&
25+
binding.parentPath.node.source.value === 'react'
26+
)
27+
}
28+
return false
29+
}
30+
31+
/** Follow only updates to a rendered state binding; unrelated handler statements stay separate. */
32+
export function stateInputs(declaration: NodePath<t.VariableDeclarator>, name: string) {
33+
const pattern = declaration.node.id
34+
const initial = declaration.get('init') as NodePath
35+
if (
36+
!t.isArrayPattern(pattern) ||
37+
!t.isIdentifier(pattern.elements[0], { name }) ||
38+
!t.isIdentifier(pattern.elements[1]) ||
39+
!initial.isCallExpression() ||
40+
!reactStateHook(initial.get('callee') as NodePath)
41+
)
42+
return undefined
43+
const writes = new Set<NodePath<t.CallExpression>>()
44+
const escapes = new Set<NodePath>()
45+
const seen = new Set<Binding>()
46+
const visit = (binding: Binding | undefined) => {
47+
if (!binding || seen.has(binding)) return
48+
seen.add(binding)
49+
for (const reference of binding.referencePaths) {
50+
const parent = reference.parentPath
51+
if (parent?.isCallExpression() && parent.node.callee === reference.node) writes.add(parent)
52+
else if (
53+
parent?.isVariableDeclarator() &&
54+
parent.node.init === reference.node &&
55+
t.isIdentifier(parent.node.id) &&
56+
parent.scope.getBinding(parent.node.id.name)?.constant &&
57+
seen.size < 16
58+
)
59+
visit(parent.scope.getBinding(parent.node.id.name))
60+
else if (parent) escapes.add(parent)
61+
}
62+
}
63+
visit(declaration.scope.getBinding(pattern.elements[1].name))
64+
return {
65+
initial: (initial.get('arguments') as NodePath[])[0],
66+
writes: [...writes],
67+
escapes: [...escapes],
68+
}
69+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { expect, it } from 'vitest'
2+
import { allChanges, compareFiles, config } from '#design-diff/tests/helpers'
3+
4+
const view = 'apps/sim/page.tsx'
5+
const settings = { ...config, themes: [] }
6+
7+
it.each([
8+
['named', "import {useState} from 'react'", 'useState'],
9+
['alias', "import {useState as state} from 'react'", 'state'],
10+
['namespace', "import * as React from 'react'", 'React.useState'],
11+
['default', "import React from 'react'", 'React.useState'],
12+
])('traces error copy assigned through a %s React state binding', async (_name, imports, hook) => {
13+
const source = (message: string) => `${imports};const ERROR='${message}';
14+
export function Page(){const [errors,setErrors]=${hook}([]);
15+
async function submit(){try {await signIn()}catch {setErrors([ERROR])}}
16+
return <form onSubmit={submit}><span>{errors.join(', ')}</span></form>}`
17+
const report = await compareFiles(
18+
{ [view]: source('Try again') },
19+
{ [view]: source('Check your email and try again') },
20+
settings
21+
)
22+
expect(report.flagged).toBe(true)
23+
expect(allChanges(report).some((change) => change.after?.location.file === view)).toBe(true)
24+
})
25+
26+
it.each([
27+
['colour', "'red'", "'blue'", 'style={{color:value}}'],
28+
['padding', '2', '4', 'style={{padding:value}}'],
29+
['visibility', 'false', 'true', 'hidden={value}'],
30+
['functional update', "old => old + 'a'", "old => old + 'b'", 'title={value}'],
31+
])('traces state-driven %s changes through setter aliases', async (_name, before, after, prop) => {
32+
const source = (update: string) => `import {useState} from 'react';
33+
export function Page(){const [value,setValue]=useState(null);const update=setValue;
34+
function click(){update(${update})}return <button onClick={click} ${prop}/>}`
35+
expect(
36+
(await compareFiles({ [view]: source(before) }, { [view]: source(after) }, settings)).flagged
37+
).toBe(true)
38+
})
39+
40+
it('keeps unrelated handler telemetry and unrendered state clean', async () => {
41+
const source = (message: string) => `import {useState} from 'react';
42+
export function Page(){const [value,setValue]=useState('ready');
43+
const [unused,setUnused]=useState('');function click(){logger.info('${message}');
44+
setUnused('${message}');setValue('done')}return <button onClick={click}>{value}</button>}`
45+
expect(
46+
(await compareFiles({ [view]: source('before') }, { [view]: source('after') }, settings))
47+
.flagged
48+
).toBe(false)
49+
})
50+
51+
it('retains conditions controlling a rendered state update', async () => {
52+
const source = (guard: string) => `import {useState} from 'react';
53+
export function Page({a,b}){const [value,setValue]=useState('ready');
54+
function click(){if(${guard})setValue('done')}return <button onClick={click}>{value}</button>}`
55+
expect(
56+
(await compareFiles({ [view]: source('a') }, { [view]: source('b') }, settings)).flagged
57+
).toBe(true)
58+
})
59+
60+
it('does not trust a local function merely named useState', async () => {
61+
const source = (message: string) => `function useState(){return ['ready',logger.info]}
62+
export function Page(){const [value,setValue]=useState();
63+
function click(){setValue('${message}')}return <button onClick={click}>{value}</button>}`
64+
expect(
65+
(await compareFiles({ [view]: source('before') }, { [view]: source('after') }, settings))
66+
.flagged
67+
).toBe(false)
68+
})
69+
70+
it('retains the branch containing a state update', async () => {
71+
const source = (body: string) => `import {useState} from 'react';
72+
export function Page({ready}){const [value,setValue]=useState('ready');
73+
function click(){${body}}return <button onClick={click}>{value}</button>}`
74+
expect(
75+
(
76+
await compareFiles(
77+
{ [view]: source("if(ready){setValue('done')}else{}") },
78+
{ [view]: source("if(ready){}else{setValue('done')}") },
79+
settings
80+
)
81+
).flagged
82+
).toBe(true)
83+
})
84+
85+
it('keeps a state/setter rename and constant hoist equivalent', async () => {
86+
const before = `import {useState} from 'react';export function Page(){
87+
const [value,setValue]=useState('ready');function click(){setValue('done')}
88+
return <button onClick={click}>{value}</button>}`
89+
const after = `import {useState} from 'react';const DONE='done';export function Page(){
90+
const [label,setLabel]=useState('ready');function click(){setLabel(DONE)}
91+
return <button onClick={click}>{label}</button>}`
92+
expect((await compareFiles({ [view]: before }, { [view]: after }, settings)).flagged).toBe(false)
93+
})

0 commit comments

Comments
 (0)