diff --git a/.github/workflows/visual-tests-demos.yml b/.github/workflows/visual-tests-demos.yml
index cdc7a9025584..5a42d15998d3 100644
--- a/.github/workflows/visual-tests-demos.yml
+++ b/.github/workflows/visual-tests-demos.yml
@@ -1212,6 +1212,16 @@ jobs:
working-directory: apps/demos
run: pnpm add ../../devextreme-installer.tgz ../../devextreme-dist-installer.tgz ../../devextreme-react-installer.tgz ../../devextreme-vue-installer.tgz ../../devextreme-angular-installer.tgz
+ # All three frameworks now bundle production-style via csp-bundle.js
+ # (esbuild + per-framework AOT plugin where applicable). Angular uses
+ # @angular/build/private's createCompilerPlugin under the hood — see
+ # apps/demos/utils/server/csp-bundle-angular.js. Pages load orders of
+ # magnitude faster than the old SystemJS dev path and the CSP profile
+ # matches production (no inline scripts, no 'unsafe-eval').
+ - name: Bundle demos for CSP check
+ working-directory: apps/demos
+ run: node utils/server/csp-bundle.js --framework=${{ matrix.FRAMEWORK }}
+
- name: Start CSP Server
run: node apps/demos/utils/server/csp-server.js 8080 &
@@ -1219,6 +1229,7 @@ jobs:
working-directory: apps/demos
env:
CSP_FRAMEWORKS: ${{ matrix.FRAMEWORK }}
+ CSP_USE_BUNDLED: '1'
CHROME_PATH: google-chrome-stable
run: node utils/server/csp-check.js
diff --git a/apps/demos/.gitignore b/apps/demos/.gitignore
index 82d6e6478557..dcb7efcf5094 100644
--- a/apps/demos/.gitignore
+++ b/apps/demos/.gitignore
@@ -34,6 +34,13 @@ Demos/**/tsconfig.json
publish-demos
csp-reports
+csp-bundled-demos
+
+# Scratch artifacts produced by utils/server/csp-bundle-angular.js. The script
+# cleans these up after a successful run, but a SIGKILL / power loss may leave
+# them behind — ignoring keeps them out of `git status`.
+utils/server/.csp-bundle-angular-*
+Demos/**/.csp-bundle-angular-patched.*.ts
.angular
angular.json
diff --git a/apps/demos/Demos/Drawer/LeftOrRightPosition/Angular/index.html b/apps/demos/Demos/Drawer/LeftOrRightPosition/Angular/index.html
index 51222cb55272..1ab1fb54a1df 100644
--- a/apps/demos/Demos/Drawer/LeftOrRightPosition/Angular/index.html
+++ b/apps/demos/Demos/Drawer/LeftOrRightPosition/Angular/index.html
@@ -6,7 +6,6 @@
-
diff --git a/apps/demos/Demos/Drawer/TopOrBottomPosition/Angular/index.html b/apps/demos/Demos/Drawer/TopOrBottomPosition/Angular/index.html
index 51222cb55272..1ab1fb54a1df 100644
--- a/apps/demos/Demos/Drawer/TopOrBottomPosition/Angular/index.html
+++ b/apps/demos/Demos/Drawer/TopOrBottomPosition/Angular/index.html
@@ -6,7 +6,6 @@
-
diff --git a/apps/demos/package.json b/apps/demos/package.json
index 47579b7e76db..f540ca2faa4d 100644
--- a/apps/demos/package.json
+++ b/apps/demos/package.json
@@ -20,7 +20,7 @@
"@angular/cli": "~21.1.5",
"@angular/common": "~21.1.0",
"@angular/compiler": "~21.2.0",
- "@angular/compiler-cli": "~21.1.0",
+ "@angular/compiler-cli": "~21.2.0",
"@angular/core": "~21.2.4",
"@angular/forms": "~21.1.0",
"@angular/platform-browser": "~21.1.0",
diff --git a/apps/demos/utils/server/csp-bundle-angular.js b/apps/demos/utils/server/csp-bundle-angular.js
new file mode 100644
index 000000000000..6eb0cc041d02
--- /dev/null
+++ b/apps/demos/utils/server/csp-bundle-angular.js
@@ -0,0 +1,916 @@
+/* eslint-disable global-require, import/no-dynamic-require */
+
+// Bundles every Angular demo into csp-bundled-demos///Angular/.
+//
+// Why this is a separate script from csp-bundle.js:
+// * React/Vue bundling is a single esbuild call per demo with at most one
+// framework plugin (`esbuild-plugin-vue3`). Angular needs AOT compilation,
+// so we wire up `@angular/build/private`'s `createCompilerPlugin` plus a
+// couple of project-specific shims (devextreme path redirect, asset
+// symlinks for off-by-one component-CSS url() refs).
+// * Keeping the Angular machinery here lets csp-bundle.js stay small and
+// framework-neutral; csp-bundle.js delegates to this file when invoked
+// with --framework=Angular.
+//
+// Run directly:
+// node apps/demos/utils/server/csp-bundle-angular.js
+// or via the unified entry:
+// node apps/demos/utils/server/csp-bundle.js --framework=Angular
+
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+const esbuild = require('esbuild');
+
+const DEMOS_APP_ROOT = path.resolve(__dirname, '..', '..');
+const REPO_ROOT = path.resolve(DEMOS_APP_ROOT, '..', '..');
+const SRC_DEMOS_DIR = path.join(DEMOS_APP_ROOT, 'Demos');
+const OUT_ROOT = path.join(DEMOS_APP_ROOT, 'csp-bundled-demos');
+const NODE_MODULES = path.join(DEMOS_APP_ROOT, 'node_modules');
+const FRAMEWORK = 'Angular';
+
+const CONCURRENCY = (() => {
+ const fromEnv = parseInt(process.env.CSP_BUNDLE_CONCURRENCY, 10);
+ if (fromEnv > 0) return fromEnv;
+ return Math.max(8, (os.cpus() || []).length - 1);
+})();
+
+const BATCH_SIZE = (() => {
+ const fromEnv = parseInt(process.env.CSP_BUNDLE_BATCH_SIZE, 10);
+ if (fromEnv > 0) return fromEnv;
+ return 16;
+})();
+
+const BATCH_CONCURRENCY = (() => {
+ const fromEnv = parseInt(process.env.CSP_BUNDLE_BATCH_CONCURRENCY, 10);
+ if (fromEnv > 0) return fromEnv;
+ return 1;
+})();
+
+// Same env knobs as csp-bundle.js — optional substring filter for local
+// smoke tests, e.g. CSP_BUNDLE_FILTER=Common/FormsOverview.
+const FILTER = (process.env.CSP_BUNDLE_FILTER || '').trim();
+
+const SHARED_TSCONFIG_TEMPLATE = path.join(__dirname, 'tsconfig.csp-bundle-angular.json');
+const GENERATED_TSCONFIG_DIR = path.join(__dirname, '.csp-bundle-angular-tsconfigs');
+
+// Demos with real bugs in their templates / component code that Angular AOT's
+// template type-checker catches but JIT (SystemJS dev path) silently accepts.
+// We can't silence template type errors via `@ts-nocheck` (the .ngtypecheck.ts
+// virtual file is a separate compilation unit). These should be fixed at the
+// demo-source level; until then they're skipped so the bundle pipeline can
+// finish cleanly and the rest of the demos make it into the CSP check.
+const KNOWN_BROKEN_DEMOS = new Set([
+ // Property access errors in .html bindings (wrong field name / typos):
+ 'ActionSheet/PopoverMode', // TS2339 Property 'id' does not exist on type 'Contact'
+ 'Chat/MessageEditing', // TS2551 'alloUpdatingLabel' (typo) → 'allowUpdating'
+ 'DataGrid/CustomizeKeyboardNavigation', // TS2551 'editOnkeyPress' (typo) → 'editOnKeyPress'
+ 'Scheduler/DragAndDrop', // TS2339 Property 'id' does not exist on type 'Task'
+ 'TreeList/CustomizeKeyboardNavigation', // same 'editOnkeyPress' typo as DataGrid
+ // (click) / (event) bindings passing more args than the handler accepts:
+ 'Charts/AreaSelectionZooming', // TS2554 Expected 0 arguments, but got 1
+ 'FileUploader/ChunkUpload', // TS2554
+ 'LoadIndicator/Overview', // TS2554
+ 'SpeechToText/Overview', // TS2554
+ 'Stepper/FormIntegration', // TS2554
+ 'TreeList/MultipleRowSelection', // TS2554
+ // Iterating an Object as if it were iterable:
+ 'Form/Grouping', // TS2488 Type 'Object' has no [Symbol.iterator]
+ 'Form/ItemCustomization', // TS2488
+ // Template references a non-existent component property:
+ 'Localization/UsingIntl', // TS2339 Property 'auto' (plus JSON-related, see below)
+ 'Localization/UsingGlobalize', // TS2339 Property 'auto'
+]);
+
+// @angular/build is transitive via @angular-devkit/build-angular (present in
+// apps/demos/package.json). Resolve through it to play nicely with pnpm.
+function resolveAngularBuildPrivate() {
+ const buildAngularPkg = require.resolve('@angular-devkit/build-angular/package.json', {
+ paths: [DEMOS_APP_ROOT],
+ });
+ const buildAngularDir = path.dirname(buildAngularPkg);
+ return require(require.resolve('@angular/build/private', { paths: [buildAngularDir] }));
+}
+
+// ngc refuses tsconfigs with empty `files` and empty `include` (TS18002). Per
+// demo we write a sibling tsconfig that extends the shared template and lists
+// the demo entry in `files`. Sanitized slug in the filename keeps concurrent
+// workers from stomping on each other.
+function writeTsconfig(name, entryPaths) {
+ fs.mkdirSync(GENERATED_TSCONFIG_DIR, { recursive: true });
+ const slug = name.replace(/[\\/]/g, '__').replace(/[^a-zA-Z0-9_.-]/g, '_');
+ const dest = path.join(GENERATED_TSCONFIG_DIR, `${slug}.tsconfig.json`);
+ // Path resolution in `extends` is relative to the file the field is in, so
+ // we point at the template via a relative ../-walk.
+ const extendsRel = path
+ .relative(path.dirname(dest), SHARED_TSCONFIG_TEMPLATE)
+ .split(path.sep)
+ .join('/');
+ const config = {
+ extends: extendsRel,
+ files: entryPaths.map((entryPath) => path.relative(path.dirname(dest), entryPath).split(path.sep).join('/')),
+ };
+ fs.writeFileSync(dest, `${JSON.stringify(config, null, 2)}\n`);
+ return dest;
+}
+
+function writeDemoTsconfig(entryPath) {
+ return writeTsconfig(path.relative(REPO_ROOT, entryPath), [entryPath]);
+}
+
+// Bundled demos load all scripts/styles externally — no inline blocks, no
+// SystemJS — so csp-server.js (cspMiddleware) keeps them on the strict
+// production CSP without a nonce. Component CSS still gets injected as
+//