Repository: symfony/symfony
File: src/Symfony/Component/Form/Extension/Core/Type/CurrencyType.php (PHP)

Code before:
use Symfony\Component\Form\Exception\LogicException;
use Symfony\Component\Intl\Currencies;
use Symfony\Component\Intl\Intl;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CurrencyType extends AbstractType
{
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'choice_loader' => function (Options $options) {
                if (!class_exists(Intl::class)) {
                    throw new LogicException(\sprintf('The "symfony/intl" component is required to use "%s". Try running "composer require symfony/intl".', static::class));
                }

                $choiceTranslationLocale = $options['choice_translation_locale'];

                return ChoiceList::loader(
                    $this,
                    new IntlCallbackChoiceLoader(
                        static function () use ($options, $choiceTranslationLocale) {
                            $activeAt = $options['active_at'];
                            $notActiveAt = $options['not_active_at'];
                            $legalTender = $options['legal_tender'];

                            if (null === $activeAt && null === $notActiveAt && null === $legalTender) {
                                return array_flip(Currencies::getNames($choiceTranslationLocale));
                            }

                            if (null !== $activeAt && null !== $notActiveAt) {
                                throw new InvalidOptionsException('The "active_at" and "not_active_at" options cannot be used together.');
                            }

                            $filteredCurrencyNames = [];

                            $active = match (true) {
                                null !== $activeAt => true,
                                null !== $notActiveAt => false,
                                default => null,
                            };

                            foreach (Currencies::getCurrencyCodes() as $code) {
                                if (!Currencies::isValidInAnyCountry($code, $legalTender, $active, $activeAt ?? $notActiveAt)) {
                                    continue;
                                }

                                $filteredCurrencyNames[$code] = Currencies::getName($code, $choiceTranslationLocale);
                            }


Review comment (bug):
This is broken. Inside a smart default of the OptionsResolver, (a Closure with `Options $options` as first parameter), `$options` is a special object reading the state of the options **during** resolution, with protection against circular references. But this requires **synchronous** reading of those options.

Capturing this `Options` object in a closure will break things:

- the OptionsResolver won't be able to detect that the default value of `choice_loader` depends on the value of `active_at` (and so won't be able to report cycles, producing invalid values instead)
- it might leak the OptionsResolver in the closure after resolution is done

Instead, those variables should be created in the function defining the smart default, and those should be the captured values.

Code after:
use Symfony\Component\Form\Exception\LogicException;
use Symfony\Component\Intl\Currencies;
use Symfony\Component\Intl\Intl;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CurrencyType extends AbstractType
{
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'choice_loader' => function (Options $options) {
                if (!class_exists(Intl::class)) {
                    throw new LogicException(\sprintf('The "symfony/intl" component is required to use "%s". Try running "composer require symfony/intl".', static::class));
                }

                $choiceTranslationLocale = $options['choice_translation_locale'];
                $activeAt = $options['active_at'];
                $notActiveAt = $options['not_active_at'];
                $legalTender = $options['legal_tender'];

                if (null !== $activeAt && null !== $notActiveAt) {
                    throw new InvalidOptionsException('The "active_at" and "not_active_at" options cannot be used together.');
                }

                $legalTenderCacheKey = match ($legalTender) {
                    null => '',
                    true => '1',
                    false => '0',
                };

                return ChoiceList::loader(
                    $this,
                    new IntlCallbackChoiceLoader(
                        static function () use ($choiceTranslationLocale, $activeAt, $notActiveAt, $legalTender) {
                            if (null === $activeAt && null === $notActiveAt && null === $legalTender) {
                                return array_flip(Currencies::getNames($choiceTranslationLocale));
                            }

                            $filteredCurrencyNames = [];

                            $active = match (true) {
                                null !== $activeAt => true,
                                null !== $notActiveAt => false,
                                default => null,
                            };

                            foreach (Currencies::getCurrencyCodes() as $code) {
                                if (!Currencies::isValidInAnyCountry($code, $legalTender, $active, $activeAt ?? $notActiveAt)) {
                                    continue;