|
| 1 | +# Member-Chain Follow-Up: Optional and Computed Access |
| 2 | + |
| 3 | +## Goal |
| 4 | + |
| 5 | +Track the next step after plain non-computed member-chain binding: |
| 6 | + |
| 7 | +- optional chaining, e.g. `x?.y.z`, `x.y?.z` |
| 8 | +- computed access, e.g. `x[y].z`, `x.y[k]` |
| 9 | + |
| 10 | +This note is intentionally a follow-up to the plain member-chain work in |
| 11 | +[2026-04-05-rsc-member-chain-binding-plan.md](./2026-04-05-rsc-member-chain-binding-plan.md). |
| 12 | +It is not part of the current cleanup / plain-chain implementation. |
| 13 | + |
| 14 | +## Current state |
| 15 | + |
| 16 | +The current implementation is intentionally narrow: |
| 17 | + |
| 18 | +- plain non-computed member chains like `x.y.z` are captured precisely |
| 19 | +- unsupported hops stop capture at the last safe prefix |
| 20 | +- examples: |
| 21 | + - `x?.y.z` -> bind `x` |
| 22 | + - `a.b?.c` -> bind `{ b: a.b }` |
| 23 | + - `x[y].z` -> bind `x` |
| 24 | + |
| 25 | +This is a reasonable conservative failure mode, but it is not full support. |
| 26 | + |
| 27 | +## Why this needs a separate design |
| 28 | + |
| 29 | +The current `BindPath` shape in [src/transforms/hoist.ts](../../src/transforms/hoist.ts) |
| 30 | +is effectively: |
| 31 | + |
| 32 | +```ts |
| 33 | +type BindPath = { |
| 34 | + key: string |
| 35 | + segments: string[] |
| 36 | +} |
| 37 | +``` |
| 38 | +
|
| 39 | +That is enough for `x.y.z` because codegen can reconstruct the bind expression |
| 40 | +from the root identifier plus dot segments. |
| 41 | +
|
| 42 | +It is not enough for: |
| 43 | +
|
| 44 | +- `x?.y.z` |
| 45 | +- `x.y?.z` |
| 46 | +- `x[y].z` |
| 47 | +- `x?.[y]` |
| 48 | +
|
| 49 | +The missing information is not cosmetic. It changes semantics. |
| 50 | +
|
| 51 | +### Optional chaining |
| 52 | +
|
| 53 | +Each hop needs to preserve whether access is optional. |
| 54 | +
|
| 55 | +Example: |
| 56 | +
|
| 57 | +```js |
| 58 | +x?.y.z |
| 59 | +``` |
| 60 | +
|
| 61 | +Reconstructing this as `x.y.z` is wrong because the bind-time access becomes |
| 62 | +stricter than the original expression. |
| 63 | +
|
| 64 | +### Computed access |
| 65 | +
|
| 66 | +Each computed hop needs the property expression, not just a string segment. |
| 67 | +
|
| 68 | +Example: |
| 69 | +
|
| 70 | +```js |
| 71 | +x[y].z |
| 72 | +``` |
| 73 | +
|
| 74 | +There is no way to reconstruct this faithfully from `["y", "z"]`, because the |
| 75 | +first `y` is an expression, not a property name. |
| 76 | +
|
| 77 | +### Computed key expressions also have their own closure semantics |
| 78 | +
|
| 79 | +Computed access is not only a codegen problem. The key expression itself may |
| 80 | +close over outer variables, or it may be local to the action. |
| 81 | +
|
| 82 | +Outer-scope key: |
| 83 | +
|
| 84 | +```js |
| 85 | +function outer() { |
| 86 | + let key = 'x' |
| 87 | + let obj = {} |
| 88 | + async function action() { |
| 89 | + 'use server' |
| 90 | + return obj[key] |
| 91 | + } |
| 92 | +} |
| 93 | +``` |
| 94 | + |
| 95 | +Both `obj` and `key` are outer captures. |
| 96 | + |
| 97 | +Action-local key: |
| 98 | + |
| 99 | +```js |
| 100 | +function outer() { |
| 101 | + let obj = {} |
| 102 | + async function action() { |
| 103 | + 'use server' |
| 104 | + let key = 'x' |
| 105 | + return obj[key] |
| 106 | + } |
| 107 | +} |
| 108 | +``` |
| 109 | + |
| 110 | +Only `obj` is an outer capture; `key` is local to the action. |
| 111 | + |
| 112 | +So any future `obj[expr]` support must treat the computed key as an ordinary |
| 113 | +expression with its own scope resolution, not just as a printable suffix on a |
| 114 | +member path. |
| 115 | + |
| 116 | +## Minimum data model change |
| 117 | + |
| 118 | +To support these cases, `BindPath` needs richer per-hop metadata. |
| 119 | + |
| 120 | +Sketch: |
| 121 | + |
| 122 | +```ts |
| 123 | +type BindSegment = |
| 124 | + | { kind: 'property'; name: string; optional: boolean } |
| 125 | + | { kind: 'computed'; expr: Node; optional: boolean } |
| 126 | + |
| 127 | +type BindPath = { |
| 128 | + key: string |
| 129 | + segments: BindSegment[] |
| 130 | +} |
| 131 | +``` |
| 132 | +
|
| 133 | +This is enough to represent: |
| 134 | +
|
| 135 | +- `.foo` |
| 136 | +- `?.foo` |
| 137 | +- `[expr]` |
| 138 | +- `?.[expr]` |
| 139 | +
|
| 140 | +The exact `key` design is still open. It only needs to support dedupe among |
| 141 | +captures that are semantically comparable. |
| 142 | +
|
| 143 | +## Required implementation areas |
| 144 | +
|
| 145 | +### 1. `scope.ts`: capture shape |
| 146 | +
|
| 147 | +In [src/transforms/scope.ts](../../src/transforms/scope.ts), |
| 148 | +`getOutermostBindableReference()` currently accumulates only plain |
| 149 | +non-computed member chains and stops at unsupported hops. |
| 150 | +
|
| 151 | +To support optional/computed access, capture analysis must preserve richer |
| 152 | +member-hop metadata instead of reducing everything to `Identifier` or |
| 153 | +`MemberExpression` with plain identifier-name segments. |
| 154 | +
|
| 155 | +That likely means changing either: |
| 156 | +
|
| 157 | +- what `referenceToNode` stores, or |
| 158 | +- adding a new structured capture representation derived from the AST |
| 159 | +
|
| 160 | +### 2. `hoist.ts`: path extraction |
| 161 | +
|
| 162 | +In [src/transforms/hoist.ts](../../src/transforms/hoist.ts), |
| 163 | +`memberExpressionToPath()` currently extracts only `string[]` segments. |
| 164 | +
|
| 165 | +That helper would need to become a structured extractor that records: |
| 166 | +
|
| 167 | +- property vs computed |
| 168 | +- optional vs non-optional |
| 169 | +- enough information to regenerate the bind expression |
| 170 | +
|
| 171 | +### 3. Dedupe semantics |
| 172 | +
|
| 173 | +Current prefix dedupe is straightforward for plain dot paths: |
| 174 | +
|
| 175 | +- `x.y` covers `x.y.z` |
| 176 | +- `x` covers everything below it |
| 177 | +
|
| 178 | +With optional/computed access, dedupe needs clearer rules. |
| 179 | +
|
| 180 | +Questions: |
| 181 | +
|
| 182 | +- does `x.y` cover `x.y?.z`? |
| 183 | +- does `x[y]` cover `x[y].z` only when the computed key expression is identical? |
| 184 | +- how should keys be normalized for comparison? |
| 185 | +
|
| 186 | +The current antichain logic should not be reused blindly. |
| 187 | +
|
| 188 | +### 3a. Support boundary for `obj[expr]` |
| 189 | +
|
| 190 | +This is still intentionally unresolved. |
| 191 | +
|
| 192 | +Possible support levels: |
| 193 | +
|
| 194 | +1. Keep current safe-prefix bailout only. |
| 195 | + Examples: |
| 196 | + - `obj[key]` -> bind `obj`, bind `key` separately if it is an outer capture |
| 197 | + - `obj[key].value` -> bind `obj`, bind `key` separately if needed |
| 198 | +
|
| 199 | +2. Support exact computed member captures only for simple shapes. |
| 200 | + Examples: |
| 201 | + - `obj[key]` |
| 202 | + - `obj[key].value` |
| 203 | + but only when we have a clear representation for both the base object and the |
| 204 | + key expression. |
| 205 | +
|
| 206 | +3. Support computed access as a first-class bind path. |
| 207 | + This would require fully defining: |
| 208 | + - path equality |
| 209 | + - prefix coverage |
| 210 | + - codegen for bind expressions |
| 211 | + - partial-object synthesis, if still applicable |
| 212 | +
|
| 213 | +At the moment, the note does not assume we will reach (3). It is entirely |
| 214 | +reasonable to stop at (1) or (2) if the semantics and implementation cost of |
| 215 | +full computed-path support are not compelling. |
| 216 | +
|
| 217 | +### 4. Bind-expression codegen |
| 218 | +
|
| 219 | +Current codegen only needs: |
| 220 | +
|
| 221 | +- `root` |
| 222 | +- `segments: string[]` |
| 223 | +
|
| 224 | +and synthesizes: |
| 225 | +
|
| 226 | +```ts |
| 227 | +root + segments.map((segment) => `.${segment}`).join('') |
| 228 | +``` |
| 229 | +
|
| 230 | +That must be replaced with codegen that can emit: |
| 231 | +
|
| 232 | +- `.foo` |
| 233 | +- `?.foo` |
| 234 | +- `[expr]` |
| 235 | +- `?.[expr]` |
| 236 | +
|
| 237 | +### 5. Partial-object synthesis |
| 238 | +
|
| 239 | +This is the hardest part. |
| 240 | +
|
| 241 | +For plain member paths, partial-object synthesis is natural: |
| 242 | +
|
| 243 | +```js |
| 244 | +{ |
| 245 | + y: { |
| 246 | + z: x.y.z |
| 247 | + } |
| 248 | +} |
| 249 | +``` |
| 250 | +
|
| 251 | +For computed access, synthesis is less obvious: |
| 252 | +
|
| 253 | +```js |
| 254 | +x[k].z |
| 255 | +``` |
| 256 | +
|
| 257 | +Questions: |
| 258 | +
|
| 259 | +- should this become an object with computed keys? |
| 260 | +- should computed paths fall back to broader binding even after we support |
| 261 | + recognizing them? |
| 262 | +- does partial-object binding remain the right representation for these cases? |
| 263 | +
|
| 264 | +This is where the design may need to diverge from plain member chains. |
| 265 | +
|
| 266 | +### 6. Comparison with Next.js |
| 267 | +
|
| 268 | +Relevant prior art is documented in |
| 269 | +[scope-manager-research/nextjs.md](./scope-manager-research/nextjs.md). |
| 270 | +
|
| 271 | +Important comparison points: |
| 272 | +
|
| 273 | +- Next.js already models optional member access in its `NamePart` structure. |
| 274 | +- Next.js does not support computed properties in the captured member-path |
| 275 | + model. |
| 276 | +- Next.js member-path capture is deliberately limited to member chains like |
| 277 | + `foo.bar.baz`. |
| 278 | +
|
| 279 | +That means: |
| 280 | +
|
| 281 | +- optional chaining has direct prior art in Next.js's capture model |
| 282 | +- computed access does not; if we support it, we are going beyond the current |
| 283 | + Next.js design |
| 284 | +
|
| 285 | +This should affect scoping decisions for the follow-up: |
| 286 | +
|
| 287 | +- optional support is an extension of an already-established member-path model |
| 288 | +- computed support is a materially larger design question, especially once key |
| 289 | + expression scope and dedupe semantics are included |
| 290 | +
|
| 291 | +## Safe intermediate target |
| 292 | +
|
| 293 | +If we want a minimal correctness-first follow-up: |
| 294 | +
|
| 295 | +1. keep the current safe-prefix bailout behavior |
| 296 | +2. add explicit tests for optional/computed cases |
| 297 | +3. only implement richer capture metadata once codegen and dedupe rules are |
| 298 | + agreed |
| 299 | +
|
| 300 | +That avoids regressing semantics while leaving room for a more precise design. |
| 301 | +
|
| 302 | +## Temporary conclusion |
| 303 | +
|
| 304 | +Current working direction: |
| 305 | +
|
| 306 | +- likely support optional chaining next, to align with Next.js's existing |
| 307 | + member-path behavior |
| 308 | +- keep computed access as a separate, open design problem for now |
| 309 | +
|
| 310 | +Rationale: |
| 311 | +
|
| 312 | +- optional chaining already has prior art in Next.js's capture model |
| 313 | +- computed access is materially more complex because it mixes: |
| 314 | + - key-expression scope resolution |
| 315 | + - path equality / dedupe rules |
| 316 | + - bind-expression codegen |
| 317 | + - unclear partial-object synthesis semantics |
| 318 | +
|
| 319 | +So the likely near-term path is: |
| 320 | +
|
| 321 | +1. support optional member chains |
| 322 | +2. keep current conservative behavior for computed access |
| 323 | +3. revisit computed support only if there is a clear use case and a concrete |
| 324 | + design that handles key-expression closure semantics correctly |
| 325 | +
|
| 326 | +## Suggested first questions before coding |
| 327 | +
|
| 328 | +1. Optional chains: |
| 329 | + Should the first supported version preserve optional syntax exactly in the |
| 330 | + bound expression, or should optional hops continue to bail out? |
| 331 | +
|
| 332 | +2. Computed access: |
| 333 | + Do we want exact support for `x[y].z`, or only a less coarse bailout than |
| 334 | + binding the whole root? |
| 335 | +
|
| 336 | +3. Binding shape: |
| 337 | + Is partial-object synthesis still the preferred strategy for computed access, |
| 338 | + or does this push us toward a different representation? |
| 339 | +
|
| 340 | +4. Computed key scope: |
| 341 | + If we support `obj[expr]`, what is the intended contract for the key |
| 342 | + expression? |
| 343 | + Specifically: |
| 344 | + - must outer variables used in `expr` always be captured independently? |
| 345 | + - do we need a representation that distinguishes outer `key` from |
| 346 | + action-local `key` when deciding support and dedupe? |
| 347 | +
|
| 348 | +5. Comparison target: |
| 349 | + Do we want to stay aligned with Next.js and continue treating computed access |
| 350 | + as out of scope, or intentionally support a broader feature set? |
| 351 | +
|
| 352 | +## Candidate tests |
| 353 | +
|
| 354 | +Add focused hoist fixtures for: |
| 355 | +
|
| 356 | +1. `x?.y.z` |
| 357 | +2. `x.y?.z` |
| 358 | +3. `x?.y?.z` |
| 359 | +4. `x[y].z` |
| 360 | +5. `x.y[k]` |
| 361 | +6. `x[y]?.z` |
| 362 | +7. `a.b?.c` as a safe-prefix bailout baseline |
| 363 | +8. `a[b].c` as a safe-prefix bailout baseline |
0 commit comments