diff --git a/docs/internals/escaping.md b/docs/internals/escaping.md
index 25802a9f0..6b2f2bb32 100644
--- a/docs/internals/escaping.md
+++ b/docs/internals/escaping.md
@@ -12,9 +12,9 @@ layers that must stay in sync**.
`(contentType, state, subType)`, emits a string that **calls** a runtime escaping
function (e.g. `LR\HtmlHelpers::escapeText(...)`, `LR\Helpers::escapeCss(...)`;
plain-text output goes through the `escape` filter instead). The subType is not
- purely state-derived: `enterHtmlAttribute` infers `js` for `on*` and `css` for
- `style` attributes, and `enterHtmlText` classifies a ` & {',
+ 'html' => new Html('bold'),
+];
+
+// text is escaped for HTML and then for the attribute
+Assert::same(
+ '',
+ $latte->renderToString('', $params),
+);
+
+Assert::same(
+ '',
+ $latte->renderToString("", $params),
+);
+
+Assert::same(
+ '',
+ $latte->renderToString('', $params),
+);
+
+// HTML is escaped only for the attribute
+Assert::same(
+ '',
+ $latte->renderToString('', $params),
+);
+
+// |noescape escapes only quotes
+Assert::same(
+ '',
+ $latte->renderToString('', $params),
+);
+
+// dynamic attribute
+Assert::same(
+ '',
+ $latte->renderToString('', $params),
+);
+
+Assert::same(
+ '',
+ $latte->renderToString('', $params),
+);
+
+Assert::same(
+ '',
+ $latte->renderToString('', ['none' => null]),
+);
+
+// n:attr
+Assert::same(
+ '',
+ $latte->renderToString('', $params),
+);
+
+Assert::same(
+ '',
+ $latte->renderToString('', $params),
+);
+
+// blocks
+Assert::same(
+ '',
+ $latte->renderToString('{define doc}{$x}{/define}', $params),
+);
+
+$latte->setLoader(new Latte\Loaders\StringLoader([
+ 'main' => ' ',
+ 'html.latte' => ' & ',
+ 'text.latte' => '{contentType text} & "',
+ 'js.latte' => '{contentType javascript}a < b',
+]));
+
+Assert::same(
+ ' ',
+ $latte->renderToString('main'),
+);
+
+// XML has no srcdoc context
+$latte->setLoader(new Latte\Loaders\StringLoader);
+Assert::same(
+ '',
+ $latte->renderToString('{contentType xml}', $params),
+);
diff --git a/tests/runtime/HtmlHelpers.escapeHtmlAttr.phpt b/tests/runtime/HtmlHelpers.escapeHtmlAttr.phpt
new file mode 100644
index 000000000..ac2569bdd
--- /dev/null
+++ b/tests/runtime/HtmlHelpers.escapeHtmlAttr.phpt
@@ -0,0 +1,17 @@
+ & " \''));
+Assert::same('{ {<!-- -->{',HtmlHelpers::escapeHtmlAttr('{ {{'));
+Assert::same('&amp;', HtmlHelpers::escapeHtmlAttr('&'));
+Assert::same('<b title="x">a & b</b>', HtmlHelpers::escapeHtmlAttr(new Html('a & b')));
+Assert::same('{', HtmlHelpers::escapeHtmlAttr(new Html('{')));
diff --git a/tests/runtime/HtmlHelpers.formatSrcdocAttribute.phpt b/tests/runtime/HtmlHelpers.formatSrcdocAttribute.phpt
new file mode 100644
index 000000000..ba1e44144
--- /dev/null
+++ b/tests/runtime/HtmlHelpers.formatSrcdocAttribute.phpt
@@ -0,0 +1,45 @@
+';
+ }
+}
+
+
+Assert::same('srcdoc', HtmlHelpers::classifyAttributeType('srcdoc'));
+Assert::same('srcdoc', HtmlHelpers::classifyAttributeType('SrcDoc'));
+
+// escaped by escapeHtmlAttr
+Assert::same('srcdoc="<b>"', HtmlHelpers::formatSrcdocAttribute('srcdoc', ''));
+Assert::same(' srcdoc="<b>"', HtmlHelpers::formatSrcdocAttribute(' srcdoc', new Html('')));
+
+// migration warnings
+foreach (['x', 'a & b', new StringObject] as $value) {
+ Assert::error(
+ fn() => HtmlHelpers::formatSrcdocAttribute('srcdoc', $value, migrationWarnings: true),
+ E_USER_WARNING,
+ "Behavior change for attribute 'srcdoc' with string value: previously it was rendered as HTML, now it is escaped as text (wrap trusted HTML in Latte\\Runtime\\Html).",
+ );
+}
+
+foreach (['a & b > " {', 1, new Html('x')] as $value) { // renders the same document as before
+ Assert::noError(fn() => HtmlHelpers::formatSrcdocAttribute('srcdoc', $value, migrationWarnings: true));
+}
+
+// null & invalid
+Assert::same('', HtmlHelpers::formatSrcdocAttribute('srcdoc', null));
+Assert::error(
+ fn() => Assert::same('', HtmlHelpers::formatSrcdocAttribute('srcdoc', [])),
+ E_USER_WARNING,
+ "Invalid value for attribute 'srcdoc': array is not allowed.",
+);