@barefootjs/jsx 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/adapters/interface.d.ts +20 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/test-adapter.d.ts.map +1 -1
  4. package/dist/expression-parser.d.ts +36 -19
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/import-map.d.ts +56 -0
  7. package/dist/import-map.d.ts.map +1 -0
  8. package/dist/import-map.js +18 -0
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +333 -199
  12. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +14 -0
  15. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/html-template.d.ts +0 -14
  19. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/imports.d.ts +2 -2
  21. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/types.d.ts +7 -0
  24. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/utils.d.ts +2 -2
  26. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  27. package/dist/scanner/js-scanner.d.ts +10 -0
  28. package/dist/scanner/js-scanner.d.ts.map +1 -1
  29. package/dist/scanner/js-scanner.js +5 -0
  30. package/dist/types.d.ts +11 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +7 -3
  33. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +438 -190
  34. package/src/__tests__/adapter-output.test.ts +49 -0
  35. package/src/__tests__/child-components-in-map.test.ts +76 -0
  36. package/src/__tests__/client-js-generation.test.ts +5 -2
  37. package/src/__tests__/import-map.test.ts +75 -0
  38. package/src/__tests__/inline-jsx-callback.test.ts +95 -0
  39. package/src/__tests__/ir-jsx-props.test.ts +5 -2
  40. package/src/__tests__/ir-sort-comparator.test.ts +212 -9
  41. package/src/__tests__/loop-item-conditional-codegen.test.ts +81 -0
  42. package/src/__tests__/map-logical-jsx-helper.test.ts +159 -0
  43. package/src/__tests__/missing-key-in-list.test.ts +49 -0
  44. package/src/__tests__/reactive-attrs-in-map.test.ts +41 -0
  45. package/src/__tests__/token-contains-ident.test.ts +27 -0
  46. package/src/__tests__/unsupported-expression.test.ts +42 -13
  47. package/src/adapters/interface.ts +20 -0
  48. package/src/adapters/test-adapter.ts +16 -1
  49. package/src/expression-parser.ts +265 -50
  50. package/src/import-map.ts +72 -0
  51. package/src/index.ts +5 -1
  52. package/src/ir-to-client-js/collect-elements.ts +3 -0
  53. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -0
  54. package/src/ir-to-client-js/control-flow/plan/loop.ts +14 -0
  55. package/src/ir-to-client-js/control-flow/stringify/insert.ts +7 -2
  56. package/src/ir-to-client-js/control-flow/stringify/loop.ts +60 -0
  57. package/src/ir-to-client-js/emit-reactive.ts +12 -3
  58. package/src/ir-to-client-js/html-template.ts +29 -3
  59. package/src/ir-to-client-js/imports.ts +2 -2
  60. package/src/ir-to-client-js/reactivity.ts +17 -1
  61. package/src/ir-to-client-js/stringify/static-array-child-init.ts +8 -4
  62. package/src/ir-to-client-js/types.ts +7 -0
  63. package/src/ir-to-client-js/utils.ts +31 -116
  64. package/src/jsx-to-ir.ts +161 -12
  65. package/src/preprocess-inline-jsx-callbacks.ts +28 -10
  66. package/src/scanner/js-scanner.ts +16 -1
  67. package/src/types.ts +12 -0
@@ -120,8 +120,19 @@ export function stringifyPlainLoop(
120
120
  reactiveEffects,
121
121
  childRefs,
122
122
  bodyIsMultiRoot,
123
+ anchored,
124
+ anchorKeyExpr,
123
125
  } = plan
124
126
 
127
+ // Whole-item conditional loops (#1665) render 0-or-1 element per item, so
128
+ // they route through `mapArrayAnchored`. The renderItem returns a fragment
129
+ // headed by a `<!--bf-loop-i:KEY-->` anchor and seeded with the
130
+ // conditional's markers; `insert(__anchor, …)` then owns the content.
131
+ if (anchored) {
132
+ stringifyAnchoredLoop(lines, plan, topIndent, anchorKeyExpr)
133
+ return
134
+ }
135
+
125
136
  // `childRefs` need `__el` as a handle to invoke the user's callback inside
126
137
  // the factory, so non-empty refs force the multi-line layout the same way
127
138
  // reactive effects do (#1244).
@@ -155,6 +166,55 @@ export function stringifyPlainLoop(
155
166
  lines.push(`${topIndent}}, '${markerId}')`)
156
167
  }
157
168
 
169
+ /**
170
+ * Emit a whole-item conditional loop via `mapArrayAnchored` (#1665).
171
+ *
172
+ * The renderItem identifies each item by an always-present
173
+ * `<!--bf-loop-i:KEY-->` anchor instead of a root element (which the item may
174
+ * not have). On CSR it returns a `DocumentFragment` of
175
+ * `[anchor, bf-cond-start, bf-cond-end]` so `insert()`'s first run has the
176
+ * markers to populate; on hydration (`__existing` is the SSR anchor Comment)
177
+ * it returns that anchor and `insert()` adopts the SSR-rendered content. The
178
+ * conditional itself is emitted by the shared reactive-effects stringifier
179
+ * with `elVar: '__anchor'`, so `insert(__anchor, …)` range-scopes the
180
+ * toggle to this item.
181
+ */
182
+ function stringifyAnchoredLoop(
183
+ lines: string[],
184
+ plan: PlainLoopPlan,
185
+ topIndent: string,
186
+ anchorKeyExpr: string,
187
+ ): void {
188
+ const {
189
+ containerVar, markerId, arrayExpr, keyFn,
190
+ paramHead, paramUnwrap, indexParam, mapPreambleWrapped, reactiveEffects,
191
+ } = plan
192
+
193
+ // The single whole-item conditional supplies the slot id used to seed the
194
+ // CSR markers so `insert()`'s first run can find and populate them.
195
+ const condSlot = reactiveEffects?.conditionals[0]?.slotId ?? null
196
+
197
+ lines.push(`${topIndent}mapArrayAnchored(() => ${arrayExpr}, ${containerVar}, ${keyFn}, (${paramHead}, ${indexParam}, __existing) => {`)
198
+ const bodyIndent = topIndent + ' '
199
+ if (paramUnwrap) lines.push(`${bodyIndent}${paramUnwrap}`)
200
+ if (mapPreambleWrapped) lines.push(`${bodyIndent}${mapPreambleWrapped}`)
201
+ lines.push(`${bodyIndent}const __anchor = __existing ?? document.createComment(\`bf-loop-i:\${${anchorKeyExpr}}\`)`)
202
+ lines.push(`${bodyIndent}let __frag = null`)
203
+ lines.push(`${bodyIndent}if (!__existing) {`)
204
+ lines.push(`${bodyIndent} __frag = document.createDocumentFragment()`)
205
+ lines.push(`${bodyIndent} __frag.appendChild(__anchor)`)
206
+ if (condSlot) {
207
+ lines.push(`${bodyIndent} __frag.appendChild(document.createComment('bf-cond-start:${condSlot}'))`)
208
+ lines.push(`${bodyIndent} __frag.appendChild(document.createComment('bf-cond-end:${condSlot}'))`)
209
+ }
210
+ lines.push(`${bodyIndent}}`)
211
+ if (reactiveEffects !== null) {
212
+ stringifyReactiveEffects(lines, reactiveEffects, { indent: bodyIndent, elVar: '__anchor', bodyIsMultiRoot: false })
213
+ }
214
+ lines.push(`${bodyIndent}return __frag ?? __anchor`)
215
+ lines.push(`${topIndent}}, '${markerId}')`)
216
+ }
217
+
158
218
  export function stringifyStaticLoop(lines: string[], plan: StaticLoopPlan): void {
159
219
  const { containerVar, arrayExpr, param, indexParam, childIndexExpr, attrsBySlot, texts, childRefs, csrMaterialize } = plan
160
220
  const hasAttrs = attrsBySlot.length > 0
@@ -92,18 +92,27 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
92
92
  const normalElems = elems.filter(e => !e.insideConditional)
93
93
 
94
94
  if (normalElems.length > 0 || conditionalElems.length > 0) {
95
+ // Persistent slot trackers for non-conditional elements. `__bfText`
96
+ // returns the node now occupying the slot; a JSX-valued expression
97
+ // (`{themeLogo(id)}`) replaces the text node with a live element, so
98
+ // the next reactive run must operate on that element, not the stale
99
+ // text node (#1663). Primitive values keep the same text node.
100
+ for (const elem of normalElems) {
101
+ const v = varSlotId(elem.slotId)
102
+ lines.push(` let __anchor_${v} = _${v}`)
103
+ }
95
104
  lines.push(` createEffect(() => {`)
96
105
  if (normalElems.length > 0) {
97
106
  // Expression is always evaluated for non-conditional elements
98
107
  lines.push(` const __val = ${expr}`)
99
108
  for (const elem of normalElems) {
100
109
  const v = varSlotId(elem.slotId)
101
- lines.push(` if (_${v} && !__val?.__isSlot) _${v}.nodeValue = String(__val ?? '')`)
110
+ lines.push(` __anchor_${v} = __bfText(__anchor_${v}, __val)`)
102
111
  }
103
112
  for (const elem of conditionalElems) {
104
113
  const v = varSlotId(elem.slotId)
105
114
  lines.push(` const [__el_${v}] = $t(__scope, '${elem.slotId}')`)
106
- lines.push(` if (__el_${v} && !__val?.__isSlot) __el_${v}.nodeValue = String(__val ?? '')`)
115
+ lines.push(` __bfText(__el_${v}, __val)`)
107
116
  }
108
117
  } else {
109
118
  // Only conditional elements — evaluate expression unconditionally
@@ -118,7 +127,7 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
118
127
  for (const elem of conditionalElems) {
119
128
  const v = varSlotId(elem.slotId)
120
129
  lines.push(` const [__el_${v}] = $t(__scope, '${elem.slotId}')`)
121
- lines.push(` if (__el_${v} && !__val?.__isSlot) __el_${v}.nodeValue = String(__val ?? '')`)
130
+ lines.push(` __bfText(__el_${v}, __val)`)
122
131
  }
123
132
  }
124
133
  lines.push(` })`)
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { AttrValue, IRAttribute, IRNode } from '../types'
6
6
  import { isBooleanAttr } from '../html-constants'
7
- import { toHtmlAttrName, attrValueToString, quotePropName, PROPS_PARAM, DATA_BF_PH, keyAttrName, loopStartMarker, loopEndMarker, freeIdsFromRefs, setIntersects, wrapExprWithLoopParams } from './utils'
7
+ import { toHtmlAttrName, attrValueToString, quotePropName, PROPS_PARAM, DATA_BF_PH, keyAttrName, loopStartMarker, loopEndMarker, loopItemMarker, freeIdsFromRefs, setIntersects, wrapExprWithLoopParams } from './utils'
8
8
  import type { LoopParamSpec } from './utils'
9
9
  import { nameForRegistryRef } from './component-scope'
10
10
  import { assertNever } from './walker'
@@ -411,6 +411,17 @@ function buildSpreadAttrsMergeCall(args: {
411
411
  * `generateCsrTemplate` (case `'component'`). Set to `true` when generating
412
412
  * the per-iteration `staticItemTemplate` for static loops.
413
413
  */
414
+ /**
415
+ * Build the per-item `<!--bf-loop-i:KEY-->` anchor comment for a whole-item
416
+ * conditional loop (#1665), where `keyExpr` is the loop's per-item key
417
+ * expression (e.g. `t.id`). Emits a live `${keyExpr}` interpolation so each
418
+ * rendered item carries its own key — `loopItemMarker` is reserved for
419
+ * already-evaluated key strings (runtime / static contexts).
420
+ */
421
+ function itemAnchorTemplate(keyExpr: string): string {
422
+ return `<!--${loopItemMarker('${' + keyExpr + '}')}-->`
423
+ }
424
+
414
425
  export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, insideLoop = false, inHoistedChildren = false): string {
415
426
  const recurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, inHoistedChildren)
416
427
  const wrapExpr = (expr: string) => wrapExprWithLoopParams(expr, loopParams)
@@ -558,7 +569,16 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
558
569
  // Case 1 — childComponent body materialize); propagating it through
559
570
  // every nested loop regressed form-builder's inner-loop Select wiring.
560
571
  const innerRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop)
561
- const childTemplate = node.children.map(innerRecurse).join('')
572
+ let childTemplate = node.children.map(innerRecurse).join('')
573
+ // Whole-item conditional loops (#1665): prepend an always-present
574
+ // `<!--bf-loop-i:KEY-->` anchor before each item's (possibly empty)
575
+ // conditional content. `mapArrayAnchored` tracks items by this anchor,
576
+ // so an item that renders no element still keeps its identity and slot.
577
+ // The key is a per-item expression, so the marker carries a live
578
+ // `${KEY}` interpolation (not the literal key text).
579
+ if (node.bodyIsItemConditional && node.key) {
580
+ childTemplate = `${itemAnchorTemplate(node.key)}${childTemplate}`
581
+ }
562
582
  const indexParam = node.index ? `, ${node.index}` : ''
563
583
  // Apply chained sort / filter for the SSR-mirror template (#1448
564
584
  // Tier B). Pre-Tier-B this just used `node.array` directly,
@@ -1499,7 +1519,13 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1499
1519
  }
1500
1520
 
1501
1521
  case 'loop': {
1502
- const childTemplate = node.children.map(recurseInLoop).join('')
1522
+ let childTemplate = node.children.map(recurseInLoop).join('')
1523
+ // Whole-item conditional loops (#1665): prepend the per-item
1524
+ // `<!--bf-loop-i:KEY-->` anchor so `mapArrayAnchored` can track items
1525
+ // that render no element. Mirrors the `irToHtmlTemplate` loop case.
1526
+ if (node.bodyIsItemConditional && node.key) {
1527
+ childTemplate = `${itemAnchorTemplate(node.key)}${childTemplate}`
1528
+ }
1503
1529
  const indexParam = node.index ? `, ${node.index}` : ''
1504
1530
  // An init-scope-only array would `undefined.map(...)` ⇒ TypeError.
1505
1531
  // Substitute an empty array; init's reconcile pass populates the loop
@@ -7,12 +7,12 @@ import type { ComponentIR, IRNode } from '../types'
7
7
  // All exports from @barefootjs/client/runtime that may be used in generated code
8
8
  export const RUNTIME_IMPORT_CANDIDATES = [
9
9
  'createSignal', 'createMemo', 'createEffect', 'onCleanup', 'onMount',
10
- 'hydrate', 'insert', 'reconcileElements', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'createDisposableEffect',
10
+ 'hydrate', 'insert', 'reconcileElements', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'createDisposableEffect',
11
11
  'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild', 'updateClientMarker',
12
12
  'createPortal',
13
13
  'provideContext', 'createContext', 'useContext',
14
14
  'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss',
15
- 'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot',
15
+ 'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
16
16
  ] as const
17
17
 
18
18
  /** @deprecated Use RUNTIME_IMPORT_CANDIDATES */
@@ -574,7 +574,23 @@ export function collectLoopChildReactiveAttrs(
574
574
  // SSR template strips the attribute (html-template) and no
575
575
  // hydrate-time binding is emitted, leaving the per-item
576
576
  // attribute permanently unset.
577
- if (!attr.clientOnly && classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind === 'none') continue
577
+ //
578
+ // `classifyReactivity` only proves reactivity for the loop item
579
+ // accessor or a *directly* read signal/memo/prop. It does NOT see
580
+ // through an opaque helper that reads an outer signal by index
581
+ // (e.g. `widthAt(i)` where `const widthAt = (i) => items()[i].w`).
582
+ // The top-level attribute path (`decideWrapForAttr`) wraps those
583
+ // anyway via the Solid-style AST-flag fallback (#940); without the
584
+ // same fallback here, the identical binding on a per-item element
585
+ // freezes at its SSR value (#1673). Apply the same `callsReactiveGetters`
586
+ // / `hasFunctionCalls` fallback so the loop-child path matches the
587
+ // top-level one — a harmless over-wrap at worst (an effect that
588
+ // subscribes to nothing runs once).
589
+ const reactive =
590
+ classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind !== 'none'
591
+ || attr.callsReactiveGetters
592
+ || attr.hasFunctionCalls
593
+ if (!attr.clientOnly && !reactive) continue
578
594
  attrs.push({
579
595
  childSlotId: el.slotId,
580
596
  attrName: attr.name,
@@ -161,10 +161,14 @@ function emitInnerLoopNested(lines: string[], plan: InnerLoopNestedInitPlan): vo
161
161
  for (const stmt of innerPreludeStatements) {
162
162
  lines.push(` ${stmt}`)
163
163
  }
164
- for (const comp of comps) {
165
- lines.push(` const __compEl = qsaChildScope(__innerEl, ${comp.selector})`)
166
- lines.push(` if (__compEl) initChild('${nameForRegistryRef(comp.componentName)}', __compEl, ${comp.propsExpr})`)
167
- }
164
+ // Each inner-loop component gets a uniquely-suffixed `__compEl` binding.
165
+ // Multiple comps share one inner `forEach` body, so a fixed name would
166
+ // re-declare `const __compEl` in the same scope (#1664).
167
+ comps.forEach((comp, i) => {
168
+ const compElVar = comps.length > 1 ? `__compEl${i}` : '__compEl'
169
+ lines.push(` const ${compElVar} = qsaChildScope(__innerEl, ${comp.selector})`)
170
+ lines.push(` if (${compElVar}) initChild('${nameForRegistryRef(comp.componentName)}', ${compElVar}, ${comp.propsExpr})`)
171
+ })
168
172
  lines.push(` })`)
169
173
  lines.push(` })`)
170
174
  lines.push(` }`)
@@ -200,6 +200,13 @@ export interface LoopCore {
200
200
  * key tracks all of its DOM nodes (#1212).
201
201
  */
202
202
  bodyIsMultiRoot?: boolean
203
+ /**
204
+ * True when the loop body is a single whole-item conditional whose at
205
+ * least one branch renders no element (#1665). Routes the loop through
206
+ * the anchored emission path (`mapArrayAnchored` + per-item
207
+ * `<!--bf-loop-i:KEY-->` anchors) so 0-or-1-element items reconcile.
208
+ */
209
+ bodyIsItemConditional?: boolean
203
210
  /**
204
211
  * Pre-computed free identifiers referenced by the `array` expression
205
212
  * (#1267). Populated during IR build from the originating AST node so
@@ -7,7 +7,12 @@ import ts from 'typescript'
7
7
  import type { AttrValue, IRTemplatePart, LoopParamBinding, FreeReference, IRNode } from '../types'
8
8
  import type { TopLevelLoop, BranchLoop } from './types'
9
9
  import { buildLoopChainExpr } from '../loop-chain'
10
- import { replaceInExprContexts } from '../scanner/js-scanner'
10
+ import {
11
+ iterateJsTokens,
12
+ isIdentifierLikeToken,
13
+ isTriviaKind,
14
+ replaceInExprContexts,
15
+ } from '../scanner/js-scanner'
11
16
  import {
12
17
  BF_KEY as DATA_KEY,
13
18
  BF_KEY_PREFIX as DATA_KEY_PREFIX,
@@ -16,10 +21,11 @@ import {
16
21
  BF_LOOP_END,
17
22
  loopStartMarker,
18
23
  loopEndMarker,
24
+ loopItemMarker,
19
25
  toHTMLAttrName as toHtmlAttrName,
20
26
  } from '@barefootjs/shared'
21
27
 
22
- export { DATA_KEY, DATA_KEY_PREFIX, DATA_BF_PH, BF_LOOP_START, BF_LOOP_END, loopStartMarker, loopEndMarker, toHtmlAttrName }
28
+ export { DATA_KEY, DATA_KEY_PREFIX, DATA_BF_PH, BF_LOOP_START, BF_LOOP_END, loopStartMarker, loopEndMarker, loopItemMarker, toHtmlAttrName }
23
29
 
24
30
  /**
25
31
  * Parameter name for the props object in generated init/template functions.
@@ -376,124 +382,33 @@ export function tokenContainsIdent(expr: string, ident: string): boolean {
376
382
  return scanForIdentifiers(expr, (token) => token === ident)
377
383
  }
378
384
 
379
- const IDENT_START_RE = /[A-Za-z_$]/
380
- const IDENT_PART_RE = /[A-Za-z0-9_$]/
381
-
382
385
  /**
383
- * Single-pass scanner over a JS-like expression string. Walks character by
384
- * character through a small state machine and invokes `predicate` on every
385
- * identifier-like token it finds in a position where bare identifiers are
386
- * semantically possible (i.e. not inside a string/comment, not the property
387
- * name in a member-access expression). Returns true on the first hit.
386
+ * Walk a JS-like expression string via the shared `ts.createScanner`-based
387
+ * lexer and invoke `predicate` on every identifier-like token found in a
388
+ * position where bare identifiers are semantically possible i.e. not
389
+ * inside a string / template-string body / comment / regex literal, and
390
+ * not the property name of a member-access expression. Returns true on the
391
+ * first hit.
392
+ *
393
+ * Delegating to `iterateJsTokens` (rather than a hand-rolled char-by-char
394
+ * state machine) means regex literals are recognised: `/it's/.test(foo)`
395
+ * no longer reads the apostrophe as a string opener, and an identifier
396
+ * inside a regex body (`/className/`) is correctly treated as opaque (#1370).
388
397
  */
389
398
  function scanForIdentifiers(expr: string, predicate: (token: string) => boolean): boolean {
390
- const n = expr.length
391
- let i = 0
392
- // 0 = code, 1 = single-quote string, 2 = double-quote string,
393
- // 3 = template literal text, 4 = template literal expression,
394
- // 5 = line comment, 6 = block comment.
395
- type State = 0 | 1 | 2 | 3 | 4 | 5 | 6
396
- let state: State = 0
397
- // For nested template expressions: stack of brace depths at each `${` push.
398
- const tmplExprStack: number[] = []
399
- // Brace depth tracked only inside template-expression state to detect when
400
- // we close back to the surrounding template-literal text.
401
- let braceDepth = 0
402
-
403
- while (i < n) {
404
- const ch = expr[i]
405
-
406
- switch (state) {
407
- case 0: // code
408
- case 4: { // template expression — same lexing rules as code
409
- // String / template literal openers
410
- if (ch === "'") { state = 1; i++; continue }
411
- if (ch === '"') { state = 2; i++; continue }
412
- if (ch === '`') { state = 3; i++; continue }
413
- // Comment openers
414
- if (ch === '/' && i + 1 < n) {
415
- const next = expr[i + 1]
416
- if (next === '/') { state = 5; i += 2; continue }
417
- if (next === '*') { state = 6; i += 2; continue }
418
- }
419
- // Track braces only inside template-expression state, so we know when
420
- // we leave `${ ... }` back to the surrounding template text.
421
- if (state === 4) {
422
- if (ch === '{') { braceDepth++; i++; continue }
423
- if (ch === '}') {
424
- if (braceDepth === 0) {
425
- // Closing `}` of `${ ... }` — pop back to enclosing tmpl state.
426
- const restored = tmplExprStack.pop()
427
- braceDepth = restored ?? 0
428
- state = 3
429
- i++
430
- continue
431
- }
432
- braceDepth--
433
- i++
434
- continue
435
- }
436
- }
437
- // Identifier start
438
- if (IDENT_START_RE.test(ch)) {
439
- let j = i + 1
440
- while (j < n && IDENT_PART_RE.test(expr[j])) j++
441
- const token = expr.slice(i, j)
442
- // Skip member-access tail: identifier preceded by `.` (ignoring
443
- // whitespace).
444
- let prev = i - 1
445
- while (prev >= 0 && (expr[prev] === ' ' || expr[prev] === '\t' || expr[prev] === '\n' || expr[prev] === '\r')) prev--
446
- const isMemberTail = prev >= 0 && expr[prev] === '.' && (prev === 0 || expr[prev - 1] !== '.') // not `..` (spread)
447
- if (!isMemberTail && predicate(token)) return true
448
- i = j
449
- continue
450
- }
451
- i++
452
- continue
453
- }
454
- case 1: { // single-quote string
455
- if (ch === '\\' && i + 1 < n) { i += 2; continue }
456
- if (ch === "'") { state = 0; i++; continue }
457
- i++
458
- continue
459
- }
460
- case 2: { // double-quote string
461
- if (ch === '\\' && i + 1 < n) { i += 2; continue }
462
- if (ch === '"') { state = 0; i++; continue }
463
- i++
464
- continue
465
- }
466
- case 3: { // template literal text
467
- if (ch === '\\' && i + 1 < n) { i += 2; continue }
468
- if (ch === '`') {
469
- // Closing the template literal; return to whatever code state we
470
- // came from (either top-level code or an outer template expression).
471
- state = tmplExprStack.length > 0 ? 4 : 0
472
- i++
473
- continue
474
- }
475
- if (ch === '$' && i + 1 < n && expr[i + 1] === '{') {
476
- // Entering `${ ... }`: save current outer brace depth, reset for new.
477
- tmplExprStack.push(braceDepth)
478
- braceDepth = 0
479
- state = 4
480
- i += 2
481
- continue
482
- }
483
- i++
484
- continue
485
- }
486
- case 5: { // line comment
487
- if (ch === '\n' || ch === '\r') { state = 0; i++; continue }
488
- i++
489
- continue
490
- }
491
- case 6: { // block comment
492
- if (ch === '*' && i + 1 < n && expr[i + 1] === '/') { state = 0; i += 2; continue }
493
- i++
494
- continue
495
- }
399
+ // Previous *significant* (non-trivia) token kind, used to skip the tail
400
+ // of a member access (`a.foo`, `a?.foo`) while still treating the head
401
+ // (`foo.bar`) and spread targets (`...foo`) as real references.
402
+ let prevSignificant: ts.SyntaxKind | undefined
403
+ for (const tok of iterateJsTokens(expr)) {
404
+ if (isTriviaKind(tok.kind)) continue
405
+ if (isIdentifierLikeToken(tok.kind)) {
406
+ const isMemberTail =
407
+ prevSignificant === ts.SyntaxKind.DotToken
408
+ || prevSignificant === ts.SyntaxKind.QuestionDotToken
409
+ if (!isMemberTail && predicate(expr.slice(tok.pos, tok.end))) return true
496
410
  }
411
+ prevSignificant = tok.kind
497
412
  }
498
413
  return false
499
414
  }