Problem/Motivation

From time to time I'm facing a very straightforward error:

php.ERROR: TypeError: Drupal\Component\Utility\Html::escape(): Argument #1 ($text) must be of type string, null given, called in /var/www/html/web/core/lib/Drupal/Component/Render/FormattableMarkup.php on line 238 in Drupal\Component\Utility\Html::escape() (line 433 of /var/www/html/web/core/lib/Drupal/Component/Utility/Html.php). {"severity_level":3,"exception":"[object] (TypeError(code: 0): Drupal\\Component\\Utility\\Html::escape(): Argument #1 ($text) must be of type string, null given, called in /var/www/html/web/core/lib/Drupal/Component/Render/FormattableMarkup.php on line 238 at /var/www/html/web/core/lib/Drupal/Component/Utility/Html.php:433)"}

This happens because method \Drupal\Component\Utility\Html::escape has strict string type for its argument:

public static function escape(string $text): string {
  return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

This was done in that issue #3441394: Remove deprecated code in utilities (basically Drupal 11.0+).

The problem is that FormattableMarkup, TranslatableMarkup, PluralTranslatableMarkup and TranslationInterface have very weak PHPDoc type-hints in core for the $arguments parameter, which is just @param array $arguments.

It means, a simple (string) new TranslatableMarkup('@name is cool', ['@name' => NULL]) will end up with an error above. The other problem, which is not related to Html::escape, but still, very heavily related, is that the key for $arguments array can't be an empty string, because in \Drupal\Component\Render\FormattableMarkup::placeholderFormat we have:

foreach ($args as $key => $value) {
  switch ($key[0]) {
    // Other switched for '%', '!', ':'.
    default:
      if (!ctype_alnum($key[0])) {
        // Warn for random placeholders that won't be replaced.
        trigger_error(sprintf('Placeholders must begin with one of the following "@", ":" or "%%", invalid placeholder (%s) with string: "%s"', $key, $string), E_USER_WARNING);
      }
      // No replacement possible therefore we can discard the argument.
      unset($args[$key]);
      break;
  }
}

It means that an empty string is essentially not a valid value for the key either. This is why it should be non-empty-string.

Steps to reproduce

(string) new TranslatableMarkup('@name is cool', ['@name' => NULL])

Proposed resolution

Narrow the $arguments parameter for those classes and the interface to: @param array<non-empty-string, string|\Stringable> $args. This will allows tools such as PHPStan/Psalm detect those issues before it deployed to production.

Remaining tasks

User interface changes

Introduced terminology

API changes

Data model changes

Release notes snippet

Issue fork drupal-3577295

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

niklan created an issue. See original summary.

niklan changed the visibility of the branch 3577295-add-strict-type to hidden.

niklan’s picture

Status: Active » Needs review

For some reason branch wasn't available for some time, thought it broken:

git checkout -b '3577295-add-strict-type' --track drupal-3577295/'3577295-add-strict-type'
fatal: 'drupal-3577295/3577295-add-strict-type' is not a commit and a branch '3577295-add-strict-type' cannot be created from it

So I've created it manually (git checkout -b '3577295-add-strict-type' drupal-3577295/main) and pushed the changes, and it ended up in the main branch. Not sure is it problem or not, don't know how to reset its state now.

niklan’s picture

Issue summary: View changes

smustgrave made their first commit to this issue’s fork.

smustgrave’s picture

Status: Needs review » Reviewed & tested by the community

This has popped up on a few issues
https://www.drupal.org/project/drupal/issues/3577075
https://www.drupal.org/project/drupal/issues/3501659

This doesn't address those issues but maybe can help future ones. not sure but I'll mark it.

sivaji_ganesh_jojodae made their first commit to this issue’s fork.

catch’s picture

Status: Reviewed & tested by the community » Needs review

Looks like there's still discussion here.

smustgrave’s picture

Not sure that thread is actionable to hold this one up?

smustgrave’s picture

Status: Needs review » Reviewed & tested by the community

@catch per #10, correct me if I'm wrong though.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work

I'm not convinced by <non-empty-string, string|\Stringable> . In my mind int, bool, float, NULL are all stringable. And if you look in

  protected static function placeholderEscape($value) {
    return $value instanceof MarkupInterface ? (string) $value : Html::escape($value);
  }

that's exactly what happens.

Looking at actual behaviour of the code

> (string) t('@name is cool', ['@name' => NULL])

   TypeError  Drupal\Component\Utility\Html::escape(): Argument #1 ($text) must be of type string, null given, called in core/lib/Drupal/Component/Render/FormattableMarkup.php on line 238.

> (string) t('@name is cool', ['@name' => 1])

= "1 is cool"

> (string) t('@name is cool', ['@name' => 1])

= "1 is cool"

> (string) t('@name is cool', ['@name' => FALSE])

= " is cool"

> (string) t('@name is cool', ['@name' => 0.0])

= "0 is cool"

> (string) t('@name is cool', ['@name' => 0.1])

= "0.1 is cool"

So I think we should change this to be <non-empty-string, string|\Stringable|int|float>

I think it is okay to not accept bools because it does not make much sense and non empty strings too but floats and ints do make sense. Let's employ Postel's law here.

mstrelan’s picture

I'm not understanding what you mean about placeholderEscape. It looks like only MarkupInterface is cast, otherwise it is passing to Html::escape which only takes a string.

I believe this only works due to type coercion, since we don't have strict types everywhere. If I add declare(strict_types=1); to FormattableMarkup and repeat your test I get this error:

$ drush php:cli
Psy Shell v0.12.22 (PHP 8.5.5 — cli) by Justin Hileman
New PHP manual is available (latest: 3.0.5). Update with `doc --update-manual`
Drush Site-Install (Drupal 12.0-dev)

> t('@name is cool', ['@name' => 1]);

   TypeError  Drupal\Component\Utility\Html::escape(): Argument #1 ($text) must be of type string, int given, called in core/lib/Drupal/Component/Render/FormattableMarkup.php on line 240.
alexpott’s picture

@mstrelan sure I think we should also add the string cast too. We can do that here or in an issue that also adds tests. But as this is only docs and we don't have strict types on I think it is acceptable to document what we actually do accept without error and that current includes in int and float - and I think it makes sense to do that.