diff --git a/changelog_unreleased/angular/20044.md b/changelog_unreleased/angular/20044.md
new file mode 100644
index 000000000..4364eaf56
--- /dev/null
+++ b/changelog_unreleased/angular/20044.md
@@ -0,0 +1,13 @@
+#### Preserve interpolations inside elements with `ngNonBindable` directive (#20044 by @splincode)
+
+
+```html
+
+{{foo+bar}}
+
+
+{{ foo + bar }}
+
+
+{{foo+bar}}
+```
diff --git a/src/language-html/embed.js b/src/language-html/embed.js
index e0ba2fa47..cd3afc43d 100644
--- a/src/language-html/embed.js
+++ b/src/language-html/embed.js
@@ -23,6 +23,7 @@ import {
isScriptLikeTag,
isVueNonHtmlBlock,
} from "./utilities/index.js";
+import { isInNgNonBindable } from "./utilities/is-in-ng-non-bindable.js";
import isVueSfcWithTypescriptScript from "./utilities/is-vue-sfc-with-typescript-script.js";
const embeddedAngularControlFlowBlocks = new Set([
@@ -109,7 +110,10 @@ function embed(path, options) {
];
};
}
- } else if (node.parent.kind === "interpolation") {
+ } else if (
+ node.parent.kind === "interpolation" &&
+ !isInNgNonBindable(path, options)
+ ) {
return async (textToDoc) => {
const textToDocOptions = {
__isInHtmlInterpolation: true, // to avoid unexpected `}}`
diff --git a/src/language-html/utilities/is-in-ng-non-bindable.js b/src/language-html/utilities/is-in-ng-non-bindable.js
new file mode 100644
index 000000000..86698cf3e
--- /dev/null
+++ b/src/language-html/utilities/is-in-ng-non-bindable.js
@@ -0,0 +1,29 @@
+import { getOrInsertComputed } from "../../utilities/get-or-insert.js";
+
+const angularNonBindableCache = new WeakMap();
+function hasOrInNgNonBindable(node) {
+ return getOrInsertComputed(angularNonBindableCache, node, (node) => {
+ if (
+ node.kind === "element" &&
+ Object.hasOwn(node.attrMap, "ngNonBindable")
+ ) {
+ return true;
+ }
+
+ if (!node.parent) {
+ return false;
+ }
+
+ return hasOrInNgNonBindable(node.parent);
+ });
+}
+
+function isInNgNonBindable(path, options) {
+ if (options.parser !== "angular") {
+ return false;
+ }
+
+ return hasOrInNgNonBindable(path.node.parent);
+}
+
+export { isInNgNonBindable };
diff --git a/tests/format/angular/ng-non-bindable/__snapshots__/format.test.js.snap b/tests/format/angular/ng-non-bindable/__snapshots__/format.test.js.snap
new file mode 100644
index 000000000..3ae72e8ca
--- /dev/null
+++ b/tests/format/angular/ng-non-bindable/__snapshots__/format.test.js.snap
@@ -0,0 +1,40 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`ng-non-bindable.html format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+ printWidth: 80 (default) |
+=====================================input======================================
+{{foo +bar}}
+
+
+ {{foo +bar}}
+ {{baz +qux}}
+
+
+{{foo +bar}}
+
+
+ @if (A) {
+ {{foo +bar}}
+ }
+
+
+=====================================output=====================================
+{{foo +bar}}
+
+
+ {{foo +bar}}
+ {{baz +qux}}
+
+
+{{ foo + bar }}
+
+
+ @if (A) {
+ {{foo +bar}}
+ }
+
+
+================================================================================
+`;
diff --git a/tests/format/angular/ng-non-bindable/embedded-language-formatting/__snapshots__/format.test.js.snap b/tests/format/angular/ng-non-bindable/embedded-language-formatting/__snapshots__/format.test.js.snap
new file mode 100644
index 000000000..6d85c6267
--- /dev/null
+++ b/tests/format/angular/ng-non-bindable/embedded-language-formatting/__snapshots__/format.test.js.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`ng-non-bindable.html - {"embeddedLanguageFormatting":"off"} format 1`] = `
+====================================options=====================================
+embeddedLanguageFormatting: "off"
+parsers: ["angular"]
+ printWidth: 80 (default) |
+=====================================input======================================
+{{foo +bar}}
+
+=====================================output=====================================
+{{foo +bar}}
+
+================================================================================
+`;
diff --git a/tests/format/angular/ng-non-bindable/embedded-language-formatting/format.test.js b/tests/format/angular/ng-non-bindable/embedded-language-formatting/format.test.js
new file mode 100644
index 000000000..4b1453b04
--- /dev/null
+++ b/tests/format/angular/ng-non-bindable/embedded-language-formatting/format.test.js
@@ -0,0 +1 @@
+runFormatTest(import.meta, ["angular"], { embeddedLanguageFormatting: "off" });
diff --git a/tests/format/angular/ng-non-bindable/embedded-language-formatting/ng-non-bindable.html b/tests/format/angular/ng-non-bindable/embedded-language-formatting/ng-non-bindable.html
new file mode 100644
index 000000000..afeebe5f4
--- /dev/null
+++ b/tests/format/angular/ng-non-bindable/embedded-language-formatting/ng-non-bindable.html
@@ -0,0 +1 @@
+{{foo +bar}}
diff --git a/tests/format/angular/ng-non-bindable/format.test.js b/tests/format/angular/ng-non-bindable/format.test.js
new file mode 100644
index 000000000..8cbc48b1a
--- /dev/null
+++ b/tests/format/angular/ng-non-bindable/format.test.js
@@ -0,0 +1 @@
+runFormatTest(import.meta, ["angular"]);
diff --git a/tests/format/angular/ng-non-bindable/ng-non-bindable.html b/tests/format/angular/ng-non-bindable/ng-non-bindable.html
new file mode 100644
index 000000000..f4caf08c5
--- /dev/null
+++ b/tests/format/angular/ng-non-bindable/ng-non-bindable.html
@@ -0,0 +1,14 @@
+{{foo +bar}}
+
+
+ {{foo +bar}}
+ {{baz +qux}}
+
+
+{{foo +bar}}
+
+
+ @if (A) {
+ {{foo +bar}}
+ }
+
diff --git a/tests/format/vue/interpolation/__snapshots__/format.test.js.snap b/tests/format/vue/interpolation/__snapshots__/format.test.js.snap
index 1923c8a0e..fef3ec101 100644
--- a/tests/format/vue/interpolation/__snapshots__/format.test.js.snap
+++ b/tests/format/vue/interpolation/__snapshots__/format.test.js.snap
@@ -75,6 +75,21 @@ parsers: ["vue"]
================================================================================
`;
+exports[`ng-non-bindable.vue format 1`] = `
+====================================options=====================================
+parsers: ["vue"]
+ printWidth: 80 (default) |
+=====================================input======================================
+{{foo +bar}}
+
+=====================================output=====================================
+
+ {{ foo + bar }}
+
+
+================================================================================
+`;
+
exports[`parenthesized.vue format 1`] = `
====================================options=====================================
parsers: ["vue"]
diff --git a/tests/format/vue/interpolation/ng-non-bindable.vue b/tests/format/vue/interpolation/ng-non-bindable.vue
new file mode 100644
index 000000000..636cb8f90
--- /dev/null
+++ b/tests/format/vue/interpolation/ng-non-bindable.vue
@@ -0,0 +1 @@
+{{foo +bar}}