Skip to content

fix: don't break a member expression that fits from a stale line number with memberExpression.linePerExpression - #844

Merged
dsherret merged 3 commits into
mainfrom
fix-member-expr-stale-line-v2
Nov 2, 2026
Merged

dsherret merged 3 commits into
mainfrom
fix-member-expr-stale-line-v2

Conversation

@dsherret

@dsherret dsherret commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Fixes #686, fixes #715. Supersedes #829, using what dprint-core 0.74 added.

With memberExpression.linePerExpression, each isMultipleLines condition compares the line the member expression starts on with the line its last item starts on, which is resolved later. The printer keeps resolved values when it goes back to an earlier point. So when the member expression was printed again somewhere else, a stale last-item line could say "multiple lines", which broke the expression, which then made it multiple lines. Expressions that fit were broken, and formatting again broke them further.

#829 reset that line with the same rule as InfoRegion: move it when the member expression starts at the same column, clear it otherwise. Redoing it with InfoRegion fixed the issues but kept both of #829's known problems, and the rule turned out to be wrong in both directions:

  • A stale "multiple lines" can't be moved. A member expression that broke only because of what followed it on the line (x(<chain>);) stayed broken after moving down to the next line at the same column (<chain>,), where it fits.
  • Clearing at every new column is what made nesting slow. The jump trace from feat(core): record why the printer went back in traces dprint#1418 showed the printer jumping back for the last item's line each time a nested member expression was printed at a new column.

So this tracks the last item's line itself, with dprint-core's actions, shift_line_number and clear_info. When the member expression is printed again at a different position:

  • It fit on one line at this column before: the line is moved. If it no longer fits, the line width breaks it.
  • Otherwise: the line is cleared and worked out again.

As in #829, the first isMultipleLines condition is also re-evaluated after the last item. When only the last item exceeds the line width, only it moves to the next line, after the conditions were resolved.

The first item stays on the first line when it's narrow

This also keeps the second item on the first line when the first item isn't wider than the indent. Breaking after it wouldn't make the lines any shorter, and it's what happens without linePerExpression:

// indentWidth: 4
foo.bar(a0.b0(a1.b1(a2.b2(z.y))))
    .end
    .more;

It's measured from where the member expression starts, so it also applies in the middle of a line. With indentWidth: 2, foo is wider than the indent, so it still goes on its own line. This only changes output with linePerExpression, when the first item is narrow.

A member expression after a shebang

With linePerExpression, console.log("1.0.0"); after a #! line was broken across two lines. The newline after the shebang was only expected, so it was written when the first statement's text was. The member expression's start line was resolved before that, on the shebang's line. The newline is now written right away when a statement follows. The output with the default config is unchanged on the corpus.

Testing

main This PR
Changes when formatted again, then settles 417 318
Still changing after 20 formats 3 0
cli/tsc/00_typescript.js 12.3s 6.0s

With the default config, which doesn't use this code, it's 317 and 0. The narrow first item changes the output of 1,179 of these files and doesn't change these numbers.

Known problem

Deeply nested member expressions in call arguments still take longer as they get deeper. #829's example at lineWidth: 60, foo.bar(a0.b0(a1.b1(... a20.b20(z.y) ...))).end.more, takes about 6s for two formats vs under 0.1s on main (about 0.1s at 10 levels deep). Each level whose arguments break moves the levels inside it to a new column, where they haven't been printed before, so they're worked out again. main is fast here because it uses the stale lines, which gives the wrong output (a1 and .b1( on separate lines) and needs more formats to settle. I didn't find this in real code: the corpus has no slow files.

The printer finds out bottom-up that each level must be multi-line: it prints every level on one line, overflows deep inside, then flips the levels outward. Each time a level flips, the levels inside it move to a new column and have to be worked out again. I tried letting InfoRegion keep its line numbers at columns where it fit on one line before. That cut the work by about 60%, but it was still exponential and broke two existing specs, because how a region breaks also depends on what follows it on the line.

Also not fixed here: const value = foo.someService.someRepository.findAllItems; breaks after = and then changes on the next format. That happens on main too, because the line breaks after = before it's known that the member expression will be multi-line.

…er with memberExpression.linePerExpression

With `memberExpression.linePerExpression`, each `isMultipleLines` condition
compares the line the member expression starts on with the line its last item
starts on, which is resolved later. When the printer went back and printed the
member expression again somewhere else, that line was from the earlier
printing, so a member expression that fit could stay broken.

The last item's line is now worked out again when it was on a later line,
and moved when the member expression fit on one line at that column before.
The first condition is also checked again after the last item, because when
only the last item exceeded the line width only it moved to the next line.
…hen the first item isn't wider than the indent with memberExpression.linePerExpression

Breaking after a first item that's no wider than the indent doesn't make the
lines any shorter, so `foo.bar(...)` stays together on the first line before
the remaining items go on their own lines, like without
`memberExpression.linePerExpression`.
…emberExpression.linePerExpression

The newline after the shebang was only expected, so it was written when the first statement's text was. The member expression's start line was resolved before that, on the shebang's line, which made a member expression that fits look like it was on multiple lines.
@dsherret
dsherret merged commit 0bfd2e8 into main Nov 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

jsx: superflous newlines inserted and unstable formatting (2nd pass gives different results than 1st) Formatting is unstable (not idempotent)

1 participant