From 2e0cc20e8e96f794f80daa6e5b1b00b783bf3408 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sat, 1 Aug 2026 11:23:31 -0700
Subject: [PATCH] harden mobile localization analysis
---
...bile-localization-candidate-rules.test.mjs | 32 ++
.../mobile-localization-rendered-variable.mjs | 9 +-
...bile-localization-translation-bindings.mjs | 78 +++-
...localization-translation-bindings.test.mjs | 111 ++++++
.../mobile-localization-value-flow.mjs | 338 +++++++++++++++---
5 files changed, 503 insertions(+), 65 deletions(-)
diff --git a/config/scripts/mobile-localization-candidate-rules.test.mjs b/config/scripts/mobile-localization-candidate-rules.test.mjs
index a06760ec232..d81c800e242 100644
--- a/config/scripts/mobile-localization-candidate-rules.test.mjs
+++ b/config/scripts/mobile-localization-candidate-rules.test.mjs
@@ -451,6 +451,38 @@ export const label = <>{box.tr('Member raw copy')}{logical('L
])
})
+ it('reports non-null renders and raw calls after snapshot or member mutations', () => {
+ const source = `
+import { t } from '@/i18n/mobile-i18n'
+const raw = (value) => value
+let source = raw
+const snapshot = source
+source = t
+const box = { tr: t }
+const alias = box
+alias.tr = raw
+const holder = { inner: { tr: t } }
+holder.inner.tr = raw
+const visible = 'Visible non-null copy'
+function render() { return 'Helper non-null copy' }
+export const label = <>{snapshot('Raw snapshot copy')}{box.tr('Raw alias mutation')}{holder.inner.tr('Raw nested mutation')}{visible!}{'Direct non-null copy'!}{render!()}>
+`
+ const candidates = collectLocalizationCandidates(
+ '/repo/mobile/src/components/Example.tsx',
+ source,
+ '/repo'
+ )
+
+ expect(candidates.map((candidate) => candidate.text)).toEqual([
+ 'Visible non-null copy',
+ 'Helper non-null copy',
+ 'Raw snapshot copy',
+ 'Raw alias mutation',
+ 'Raw nested mutation',
+ 'Direct non-null copy'
+ ])
+ })
+
it('finds user-visible subject fallbacks in returned rows', () => {
const source = `
export function toRow(item) {
diff --git a/config/scripts/mobile-localization-rendered-variable.mjs b/config/scripts/mobile-localization-rendered-variable.mjs
index 87e57ece8bb..f82d1cfe891 100644
--- a/config/scripts/mobile-localization-rendered-variable.mjs
+++ b/config/scripts/mobile-localization-rendered-variable.mjs
@@ -13,7 +13,8 @@ function isDirectDisplayExpressionParent(parent, child) {
if (
ts.isParenthesizedExpression(parent) ||
ts.isAsExpression(parent) ||
- ts.isSatisfiesExpression(parent)
+ ts.isSatisfiesExpression(parent) ||
+ ts.isNonNullExpression(parent)
) {
return parent.expression === child
}
@@ -44,6 +45,9 @@ export function isRenderedJsxExpression(node) {
if (
ts.isConditionalExpression(current) ||
ts.isParenthesizedExpression(current) ||
+ ts.isAsExpression(current) ||
+ ts.isSatisfiesExpression(current) ||
+ ts.isNonNullExpression(current) ||
ts.isTemplateExpression(current) ||
ts.isNoSubstitutionTemplateLiteral(current)
) {
@@ -175,7 +179,8 @@ function unwrapAliasExpression(node) {
if (
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node) ||
- ts.isSatisfiesExpression(node)
+ ts.isSatisfiesExpression(node) ||
+ ts.isNonNullExpression(node)
) {
return unwrapAliasExpression(node.expression)
}
diff --git a/config/scripts/mobile-localization-translation-bindings.mjs b/config/scripts/mobile-localization-translation-bindings.mjs
index 0cd9f0305b8..da1f41d9539 100644
--- a/config/scripts/mobile-localization-translation-bindings.mjs
+++ b/config/scripts/mobile-localization-translation-bindings.mjs
@@ -113,28 +113,50 @@ function expressionResolution(node, use, bindings, mode, seen = new Set()) {
) {
return { descriptors: new Set([stableDescriptor.descriptor]), unknown: false }
}
+ const potentialDescriptors = binding ? bindings.potentialDescriptors?.get(binding) : undefined
+ if (mode === 'all' && potentialDescriptors?.size) {
+ return { descriptors: new Set(potentialDescriptors), unknown: false }
+ }
if (!binding || seen.has(binding)) {
return emptyResolution(true)
}
+ if (mode === 'all') {
+ const cached = bindings.allResolutionCache?.get(binding)
+ if (cached) {
+ return cached
+ }
+ if (bindings.allResolutionInProgress?.has(binding)) {
+ return emptyResolution(true)
+ }
+ bindings.allResolutionInProgress ??= new Set()
+ bindings.allResolutionInProgress.add(binding)
+ }
const nextSeen = new Set(seen).add(binding)
const sources = bindings.valueFlow.valueSources(expression, use, mode === 'all')
if (sources.length === 0) {
+ bindings.allResolutionInProgress?.delete(binding)
return emptyResolution(true)
}
- return mergeResolutions(
+ const resolution = mergeResolutions(
sources.map((source) =>
source.propertyName
? propertyResolution(
source.expression,
source.propertyName,
- use,
+ source.node,
bindings,
mode,
nextSeen
)
- : expressionResolution(source.expression, use, bindings, mode, nextSeen)
+ : expressionResolution(source.expression, source.node, bindings, mode, nextSeen)
)
)
+ if (mode === 'all') {
+ bindings.allResolutionInProgress.delete(binding)
+ bindings.allResolutionCache ??= new Map()
+ bindings.allResolutionCache.set(binding, resolution)
+ }
+ return resolution
}
const member = memberParts(expression)
@@ -196,11 +218,12 @@ function collectStableDescriptors(valueFlow, rootDescriptors, resolveBinding) {
[...rootDescriptors].map(([binding, descriptor]) => [binding, { descriptor, writes: [] }])
)
const dependents = new Map()
- for (const [binding, writes] of valueFlow.bindingWrites()) {
- if (writes.length !== 1 || writes[0].propertyName || writes[0].conditional) {
+ for (const [binding] of valueFlow.bindingWrites()) {
+ const write = valueFlow.stableBindingWrite(binding)
+ if (!write || write.propertyName) {
continue
}
- const source = valueFlow.unwrapExpression(writes[0].expression)
+ const source = valueFlow.unwrapExpression(write.expression)
if (!ts.isIdentifier(source)) {
continue
}
@@ -211,7 +234,7 @@ function collectStableDescriptors(valueFlow, rootDescriptors, resolveBinding) {
const entries = dependents.get(sourceBinding) ?? []
entries.push({
binding,
- write: writes[0],
+ write,
always: valueFlow.isImmutableBinding(binding)
})
dependents.set(sourceBinding, entries)
@@ -235,6 +258,42 @@ function collectStableDescriptors(valueFlow, rootDescriptors, resolveBinding) {
return descriptors
}
+function collectPotentialDescriptors(valueFlow, rootDescriptors, resolveBinding) {
+ const descriptors = new Map(
+ [...rootDescriptors].map(([binding, descriptor]) => [binding, new Set([descriptor])])
+ )
+ const dependents = new Map()
+ for (const [binding, writes] of valueFlow.bindingWrites()) {
+ for (const write of writes) {
+ if (write.propertyName) {
+ continue
+ }
+ const source = valueFlow.unwrapExpression(write.expression)
+ const sourceBinding = ts.isIdentifier(source) ? resolveBinding(source) : undefined
+ if (!sourceBinding) {
+ continue
+ }
+ const entries = dependents.get(sourceBinding) ?? new Set()
+ entries.add(binding)
+ dependents.set(sourceBinding, entries)
+ }
+ }
+ const queue = [...descriptors.keys()]
+ for (let index = 0; index < queue.length; index += 1) {
+ const source = queue[index]
+ for (const target of dependents.get(source) ?? []) {
+ const targetDescriptors = descriptors.get(target) ?? new Set()
+ const previousSize = targetDescriptors.size
+ descriptors.get(source).forEach((descriptor) => targetDescriptors.add(descriptor))
+ descriptors.set(target, targetDescriptors)
+ if (targetDescriptors.size > previousSize) {
+ queue.push(target)
+ }
+ }
+ }
+ return descriptors
+}
+
function bindingName(binding) {
return binding.name && ts.isIdentifier(binding.name) ? binding.name.text : undefined
}
@@ -339,6 +398,11 @@ export function collectMobileTranslationBindings(sourceFile) {
rootDescriptors,
bindings.resolveBinding
)
+ bindings.potentialDescriptors = collectPotentialDescriptors(
+ bindings.valueFlow,
+ rootDescriptors,
+ bindings.resolveBinding
+ )
collectPotentialBindingNames(bindings)
return bindings
}
diff --git a/config/scripts/mobile-localization-translation-bindings.test.mjs b/config/scripts/mobile-localization-translation-bindings.test.mjs
index 995288d6b12..93313535d77 100644
--- a/config/scripts/mobile-localization-translation-bindings.test.mjs
+++ b/config/scripts/mobile-localization-translation-bindings.test.mjs
@@ -84,6 +84,28 @@ export const labels = [replaced('Raw visible copy'), prefixed('title')]
expect(calls.map((call) => call.keys)).toEqual([['second.title']])
})
+ it('evaluates alias snapshots at their assignment sites', () => {
+ const sourceText = `
+import { t } from '@/i18n/mobile-i18n'
+const raw = (value) => value
+let source = raw
+const snapshot = source
+source = t
+const box = { tr: raw }
+const { tr: memberSnapshot } = box
+box.tr = t
+export const labels = [
+ snapshot('Raw binding snapshot'),
+ memberSnapshot('Raw member snapshot'),
+ source('example.binding'),
+ box.tr('example.member')
+]
+`
+ const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
+
+ expect(calls.map((call) => call.keys)).toEqual([['example.binding'], ['example.member']])
+ })
+
it('tracks member and logical assignments at each call', () => {
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
@@ -107,6 +129,65 @@ export const labels = [
expect(calls.map((call) => call.keys)).toEqual([['example.element'], ['example.logical']])
})
+ it('tracks aliased and nested property writes', () => {
+ const sourceText = `
+import { t } from '@/i18n/mobile-i18n'
+const raw = (value) => value
+const box = { tr: t }
+const alias = box
+alias['tr'] = raw
+const holder = { inner: { tr: t } }
+holder.inner.tr('example.nested')
+holder.inner.tr = raw
+const assigned = { inner: {} }
+assigned.inner.tr = t
+export const labels = [
+ box.tr('Raw aliased mutation'),
+ holder.inner.tr('Raw nested mutation'),
+ assigned.inner.tr('example.nestedAssigned')
+]
+`
+ const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
+
+ expect(calls.map((call) => call.keys)).toEqual([['example.nested'], ['example.nestedAssigned']])
+ })
+
+ it('applies statically determined logical assignments', () => {
+ const sourceText = `
+import { t } from '@/i18n/mobile-i18n'
+const raw = (value) => value
+let nullishKeep = t
+nullishKeep ??= raw
+let orKeep = t
+orKeep ||= raw
+let nullishAssign = undefined
+nullishAssign ??= t
+let andReplace = t
+andReplace &&= raw
+const propertyKeep = { tr: t }
+propertyKeep.tr ??= raw
+const propertyAssign = {}
+propertyAssign.tr ??= t
+export const labels = [
+ nullishKeep('example.nullishKeep'),
+ orKeep('example.orKeep'),
+ nullishAssign('example.nullishAssign'),
+ propertyKeep.tr('example.propertyKeep'),
+ propertyAssign.tr('example.propertyAssign'),
+ andReplace('Raw logical replacement')
+]
+`
+ const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
+
+ expect(calls.map((call) => call.keys)).toEqual([
+ ['example.nullishKeep'],
+ ['example.orKeep'],
+ ['example.nullishAssign'],
+ ['example.propertyKeep'],
+ ['example.propertyAssign']
+ ])
+ })
+
it('unwraps non-null translator expressions', () => {
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
@@ -130,6 +211,18 @@ export const labels = next.map((value) => value.label)
expect(calls).toEqual([])
})
+ it('keeps translator identity across self-referential member writes', () => {
+ const sourceText = `
+import { t } from '@/i18n/mobile-i18n'
+const holder = { current: t }
+holder.current = holder.current
+export const label = holder.current('example.selfReference')
+`
+ const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
+
+ expect(calls.map((call) => call.keys)).toEqual([['example.selfReference']])
+ })
+
it('resolves long mutable reverse alias chains without whole-file fixed-point rescans', () => {
const aliases = Array.from({ length: 1500 }, (_, index) =>
index === 1499 ? `let alias${index} = t` : `let alias${index} = alias${index + 1}`
@@ -137,6 +230,7 @@ export const labels = next.map((value) => value.label)
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
${aliases}
+alias1499 ??= (value) => value
export const label = alias0('example.scaled')
`
const startedAt = performance.now()
@@ -145,4 +239,21 @@ export const label = alias0('example.scaled')
expect(calls.map((call) => call.keys)).toEqual([['example.scaled']])
expect(performance.now() - startedAt).toBeLessThan(1000)
})
+
+ it('collects long potential alias chains after a changing logical write', () => {
+ const aliases = Array.from({ length: 1500 }, (_, index) =>
+ index === 1499 ? `let alias${index} = t` : `let alias${index} = alias${index + 1}`
+ ).join('\n')
+ const sourceText = `
+import { t } from '@/i18n/mobile-i18n'
+${aliases}
+alias1499 &&= (value) => value
+export const label = alias0('Raw potential alias')
+`
+ const startedAt = performance.now()
+ const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
+
+ expect(calls).toEqual([])
+ expect(performance.now() - startedAt).toBeLessThan(1000)
+ })
})
diff --git a/config/scripts/mobile-localization-value-flow.mjs b/config/scripts/mobile-localization-value-flow.mjs
index a8fe9352a86..856248996db 100644
--- a/config/scripts/mobile-localization-value-flow.mjs
+++ b/config/scripts/mobile-localization-value-flow.mjs
@@ -7,6 +7,11 @@ const ASSIGNMENT_KINDS = new Set([
ts.SyntaxKind.BarBarEqualsToken,
ts.SyntaxKind.QuestionQuestionEqualsToken
])
+const LOGICAL_ASSIGNMENT_KINDS = new Set([
+ ts.SyntaxKind.AmpersandAmpersandEqualsToken,
+ ts.SyntaxKind.BarBarEqualsToken,
+ ts.SyntaxKind.QuestionQuestionEqualsToken
+])
function unwrapExpression(node) {
if (
@@ -30,6 +35,21 @@ function propertyNameText(name) {
return undefined
}
+function memberParts(node) {
+ const expression = unwrapExpression(node)
+ if (ts.isPropertyAccessExpression(expression)) {
+ return { object: expression.expression, propertyName: expression.name.text }
+ }
+ if (
+ ts.isElementAccessExpression(expression) &&
+ expression.argumentExpression &&
+ ts.isStringLiteralLike(expression.argumentExpression)
+ ) {
+ return { object: expression.expression, propertyName: expression.argumentExpression.text }
+ }
+ return undefined
+}
+
function statementContainer(node) {
let current = node
while (current.parent) {
@@ -57,10 +77,7 @@ function isConstBinding(binding) {
)
}
-function isDefiniteBefore(write, use) {
- if (write.conditional) {
- return false
- }
+function isOrderedBefore(write, use) {
const writeStatement = statementContainer(write.node)
const useStatement = statementContainer(use)
return Boolean(
@@ -71,6 +88,10 @@ function isDefiniteBefore(write, use) {
)
}
+function isDefiniteBefore(write, use) {
+ return !write.conditional && isOrderedBefore(write, use)
+}
+
function destructuredTargets(name) {
const target = unwrapExpression(name)
if (ts.isObjectBindingPattern(target)) {
@@ -101,77 +122,57 @@ function destructuredTargets(name) {
export function createMobileLocalizationValueFlow(sourceFile, bindings) {
const writes = new Map()
const propertyWrites = new Map()
+ const pendingPropertyWrites = []
- function addWrite(identifier, expression, node, propertyName, conditional = false) {
+ function addWrite(identifier, expression, node, propertyName, operator) {
const binding = bindings.resolveBinding(identifier)
if (!binding) {
return
}
const entries = writes.get(binding) ?? []
entries.push({
- conditional,
+ conditional: LOGICAL_ASSIGNMENT_KINDS.has(operator),
expression,
node,
+ operator,
position: node.getStart(sourceFile),
propertyName
})
writes.set(binding, entries)
}
- function addTargetWrites(name, expression, node, conditional = false) {
+ function addTargetWrites(name, expression, node, operator) {
const target = unwrapExpression(name)
if (ts.isIdentifier(target)) {
- addWrite(target, expression, node, undefined, conditional)
+ addWrite(target, expression, node, undefined, operator)
return
}
for (const entry of destructuredTargets(target)) {
- addWrite(entry.identifier, expression, node, entry.propertyName, conditional)
+ addWrite(entry.identifier, expression, node, entry.propertyName, operator)
}
}
function memberWriteTarget(node) {
- const target = unwrapExpression(node)
- if (ts.isPropertyAccessExpression(target)) {
- const object = unwrapExpression(target.expression)
- return ts.isIdentifier(object)
- ? { identifier: object, propertyName: target.name.text }
- : undefined
- }
- if (
- ts.isElementAccessExpression(target) &&
- target.argumentExpression &&
- ts.isStringLiteralLike(target.argumentExpression)
- ) {
- const object = unwrapExpression(target.expression)
- return ts.isIdentifier(object)
- ? { identifier: object, propertyName: target.argumentExpression.text }
- : undefined
- }
- return undefined
- }
-
- function addPropertyWrite(target, expression, node, conditional) {
- const binding = bindings.resolveBinding(target.identifier)
- if (!binding) {
- return
- }
- const byName = propertyWrites.get(binding) ?? new Map()
- const entries = byName.get(target.propertyName) ?? []
- entries.push({ conditional, expression, node, position: node.getStart(sourceFile) })
- byName.set(target.propertyName, entries)
- propertyWrites.set(binding, byName)
+ const member = memberParts(node)
+ return member ? { ...member, target: unwrapExpression(node) } : undefined
}
function visit(node) {
if (ts.isVariableDeclaration(node) && node.initializer) {
addTargetWrites(node.name, node.initializer, node)
} else if (ts.isBinaryExpression(node) && ASSIGNMENT_KINDS.has(node.operatorToken.kind)) {
- const conditional = node.operatorToken.kind !== ts.SyntaxKind.EqualsToken
const memberTarget = memberWriteTarget(node.left)
if (memberTarget) {
- addPropertyWrite(memberTarget, node.right, node, conditional)
+ pendingPropertyWrites.push({
+ ...memberTarget,
+ conditional: LOGICAL_ASSIGNMENT_KINDS.has(node.operatorToken.kind),
+ expression: node.right,
+ node,
+ operator: node.operatorToken.kind,
+ position: node.getStart(sourceFile)
+ })
} else {
- addTargetWrites(node.left, node.right, node, conditional)
+ addTargetWrites(node.left, node.right, node, node.operatorToken.kind)
}
}
ts.forEachChild(node, visit)
@@ -181,10 +182,72 @@ export function createMobileLocalizationValueFlow(sourceFile, bindings) {
for (const entries of writes.values()) {
entries.sort((left, right) => left.position - right.position)
}
- for (const byName of propertyWrites.values()) {
- for (const entries of byName.values()) {
- entries.sort((left, right) => left.position - right.position)
+ function staticValueState(node) {
+ const expression = unwrapExpression(node)
+ if (
+ expression.kind === ts.SyntaxKind.NullKeyword ||
+ ts.isVoidExpression(expression) ||
+ (ts.isIdentifier(expression) &&
+ expression.text === 'undefined' &&
+ !bindings.resolveBinding(expression))
+ ) {
+ return { nullish: true, truthy: false }
}
+ if (expression.kind === ts.SyntaxKind.TrueKeyword) {
+ return { nullish: false, truthy: true }
+ }
+ if (expression.kind === ts.SyntaxKind.FalseKeyword) {
+ return { nullish: false, truthy: false }
+ }
+ if (ts.isStringLiteralLike(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
+ return { nullish: false, truthy: expression.text.length > 0 }
+ }
+ if (ts.isNumericLiteral(expression)) {
+ return { nullish: false, truthy: Number(expression.text) !== 0 }
+ }
+ if (ts.isBigIntLiteral(expression)) {
+ return { nullish: false, truthy: expression.text !== '0n' }
+ }
+ if (
+ ts.isArrowFunction(expression) ||
+ ts.isFunctionExpression(expression) ||
+ ts.isClassExpression(expression) ||
+ ts.isObjectLiteralExpression(expression) ||
+ ts.isArrayLiteralExpression(expression) ||
+ ts.isNewExpression(expression)
+ ) {
+ return { nullish: false, truthy: true }
+ }
+ if (ts.isIdentifier(expression)) {
+ const binding = bindings.resolveBinding(expression)
+ if (binding && bindings.rootDescriptors?.has(binding)) {
+ return { nullish: false, truthy: true }
+ }
+ }
+ return { nullish: undefined, truthy: undefined }
+ }
+
+ function logicalWriteAction(operator, expressions, knownEmpty = false) {
+ const values = expressions.length > 0 ? expressions : knownEmpty ? [undefined] : []
+ const actions = new Set()
+ for (const expression of values) {
+ const state =
+ expression === undefined ? { nullish: true, truthy: false } : staticValueState(expression)
+ const applies =
+ operator === ts.SyntaxKind.QuestionQuestionEqualsToken
+ ? state.nullish
+ : operator === ts.SyntaxKind.BarBarEqualsToken
+ ? state.truthy === undefined
+ ? undefined
+ : !state.truthy
+ : state.truthy
+ actions.add(applies === true ? 'assign' : applies === false ? 'keep' : 'unknown')
+ }
+ return actions.size === 1 ? [...actions][0] : 'unknown'
+ }
+
+ function isUninitializedBinding(binding) {
+ return ts.isVariableDeclaration(binding) && binding.initializer === undefined
}
function selectedWrites(identifier, use, all) {
@@ -197,15 +260,145 @@ export function createMobileLocalizationValueFlow(sourceFile, bindings) {
const available = entries.filter((entry) => entry.position < usePosition)
let selected = []
for (const entry of available) {
- if (isDefiniteBefore(entry, use)) {
+ const action = entry.conditional
+ ? logicalWriteAction(
+ entry.operator,
+ selected.flatMap((write) => (write.propertyName ? [] : [write.expression])),
+ selected.length === 0 && isUninitializedBinding(binding)
+ )
+ : 'assign'
+ if (!isOrderedBefore(entry, use)) {
+ if (action !== 'keep') {
+ selected.push(entry)
+ }
+ continue
+ }
+ if (!entry.conditional) {
selected = [entry]
- } else {
+ continue
+ }
+ if (action === 'assign') {
+ selected = [entry]
+ } else if (action === 'unknown') {
selected.push(entry)
}
}
return selected
}
+ function locationKey(location) {
+ return `${location.binding.pos}:${location.path.join('\0')}`
+ }
+
+ function objectLocations(node, use = node, seen = new Set()) {
+ const expression = unwrapExpression(node)
+ if (ts.isIdentifier(expression)) {
+ const binding = bindings.resolveBinding(expression)
+ if (!binding) {
+ return []
+ }
+ const direct = [{ binding, path: [] }]
+ if (seen.has(binding)) {
+ return direct
+ }
+ const nextSeen = new Set(seen).add(binding)
+ const sources = selectedWrites(expression, use, false)
+ const resolved = sources.flatMap((source) => {
+ const locations = objectLocations(source.expression, source.node, nextSeen)
+ return source.propertyName
+ ? locations.map((location) => ({
+ binding: location.binding,
+ path: [...location.path, source.propertyName]
+ }))
+ : locations
+ })
+ const locations = resolved.length > 0 ? resolved : direct
+ return [...new Map(locations.map((location) => [locationKey(location), location])).values()]
+ }
+ const member = memberParts(expression)
+ if (!member) {
+ return []
+ }
+ return objectLocations(member.object, use, seen).map((location) => ({
+ binding: location.binding,
+ path: [...location.path, member.propertyName]
+ }))
+ }
+
+ function indexedPropertyWrites(location) {
+ const byPath = propertyWrites.get(location.binding) ?? new Map()
+ propertyWrites.set(location.binding, byPath)
+ const key = location.path.join('\0')
+ const entries = byPath.get(key) ?? []
+ byPath.set(key, entries)
+ return entries
+ }
+
+ for (const entry of pendingPropertyWrites) {
+ const targetLocations = objectLocations(entry.object, entry.node).map((location) => ({
+ binding: location.binding,
+ path: [...location.path, entry.propertyName]
+ }))
+ const rightMember = memberParts(entry.expression)
+ const rightLocations = rightMember
+ ? objectLocations(rightMember.object, entry.node).map((location) => ({
+ binding: location.binding,
+ path: [...location.path, rightMember.propertyName]
+ }))
+ : []
+ const rightKeys = new Set(rightLocations.map(locationKey))
+ const selfAssignment =
+ targetLocations.length > 0 &&
+ targetLocations.every((location) => rightKeys.has(locationKey(location)))
+ const indexed = { ...entry, selfAssignment }
+ for (const location of targetLocations) {
+ indexedPropertyWrites(location).push(indexed)
+ }
+ }
+ for (const byPath of propertyWrites.values()) {
+ for (const entries of byPath.values()) {
+ entries.sort((left, right) => left.position - right.position)
+ }
+ }
+
+ function propertyWriteEntries(node, propertyName, use, all) {
+ const usePosition = use.getStart(sourceFile)
+ const entries = objectLocations(node, use).flatMap((location) => {
+ const key = [...location.path, propertyName].join('\0')
+ return propertyWrites.get(location.binding)?.get(key) ?? []
+ })
+ return [
+ ...new Set(
+ entries.filter((entry) => !entry.selfAssignment && (all || entry.position < usePosition))
+ )
+ ].sort((left, right) => left.position - right.position)
+ }
+
+ function applyPropertyWrites(resolved, known, node, propertyName, use, all) {
+ for (const entry of propertyWriteEntries(node, propertyName, use, all)) {
+ if (all) {
+ resolved.push(entry.expression)
+ continue
+ }
+ const action = entry.conditional
+ ? logicalWriteAction(entry.operator, resolved, known && resolved.length === 0)
+ : 'assign'
+ if (!isOrderedBefore(entry, use)) {
+ if (action !== 'keep') {
+ resolved.push(entry.expression)
+ }
+ continue
+ }
+ if (action === 'assign') {
+ resolved.splice(0, resolved.length, entry.expression)
+ known = true
+ } else if (action === 'unknown') {
+ resolved.push(entry.expression)
+ }
+ }
+ return known
+ }
+
function propertyValues(node, propertyName, use = node, seen = new Set(), all = false) {
const expression = unwrapExpression(node)
if (ts.isIdentifier(expression)) {
@@ -227,18 +420,29 @@ export function createMobileLocalizationValueFlow(sourceFile, bindings) {
resolved.push(...matches)
}
}
- const entries = propertyWrites.get(binding)?.get(propertyName) ?? []
- const usePosition = use.getStart(sourceFile)
- for (const entry of all ? entries : entries.filter((value) => value.position < usePosition)) {
- if (all) {
- resolved.push(entry.expression)
- } else if (isDefiniteBefore(entry, use)) {
- resolved.splice(0, resolved.length, entry.expression)
- known = true
+ known = applyPropertyWrites(resolved, known, expression, propertyName, use, all)
+ return known || (all && resolved.length > 0) ? resolved : undefined
+ }
+ const member = memberParts(expression)
+ if (member) {
+ const objectValues = propertyValues(
+ member.object,
+ member.propertyName,
+ use,
+ new Set(seen),
+ all
+ )
+ const resolved = []
+ let known = objectValues !== undefined
+ for (const value of objectValues ?? []) {
+ const matches = propertyValues(value, propertyName, use, new Set(seen), all)
+ if (matches === undefined) {
+ known = false
} else {
- resolved.push(entry.expression)
+ resolved.push(...matches)
}
}
+ known = applyPropertyWrites(resolved, known, expression, propertyName, use, all)
return known || (all && resolved.length > 0) ? resolved : undefined
}
if (!ts.isObjectLiteralExpression(expression)) {
@@ -307,6 +511,27 @@ export function createMobileLocalizationValueFlow(sourceFile, bindings) {
return selectedWrites(identifier, use, all)
}
+ function stableBindingWrite(binding) {
+ let source
+ for (const entry of writes.get(binding) ?? []) {
+ if (!entry.conditional) {
+ if (source) {
+ return undefined
+ }
+ source = entry
+ continue
+ }
+ if (
+ !source ||
+ source.propertyName ||
+ logicalWriteAction(entry.operator, [source.expression]) !== 'keep'
+ ) {
+ return undefined
+ }
+ }
+ return source
+ }
+
function bindingWrites() {
return writes.entries()
}
@@ -320,6 +545,7 @@ export function createMobileLocalizationValueFlow(sourceFile, bindings) {
bindingWrites,
isImmutableBinding: isConstBinding,
propertyValues,
+ stableBindingWrite,
unwrapExpression,
valueExpressions,
valueSources,