Designated initializer different behavior before and after c++20

Why does it compile successfully before c++20 ...

The program is ill-formed prior to C++20.

Designited initialisers did not exist in the language prior to C++20. It compiles because of a language extension.

What changed in c++20 that made it not compile anymore?

The program is still ill-formed in C++20.

Designated initialisers are introduced to the language in C++20, and it appears that the rules are slightly different from what the language extension does. The related rules are (from latest draft):

[dcl.init.list] List-initialization of an object or reference of type T is defined as follows:

  • If the braced-init-list contains a designated-initializer-list, T shall be an aggregate class. ...

  • ...

[dcl.init.aggr] An aggregate is an array or a class ([class]) with

  • no user-declared or inherited constructors ([class.ctor]),

  • ...

The behavioural difference of the language extension prior to C++20 may be related to the change in definition of what is an aggregate, as explained by NathanOliver

In C++20, your class is no longer an aggregate. Since it is not an aggregate, you cannot use a designated initializer. This change is the result of P1008 which removed the presence of user provided defaulted or deleted constructors as qualifying for being an aggregate. The example given for why this change needed to be made was:

struct X {
  int i{4};
  X() = default;

int main() {
  X x1(3); // ill-formed - no matching c’tor
  X x2{3}; // compiles!

where X x2{3} should not compile but it does because X() = default; doesn't stop it from being an aggregate.