Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SPIKE] Throw attempting to override an object value with a non-object #4809

Closed

Conversation

romaricpascal
Copy link
Member

@romaricpascal romaricpascal commented Feb 29, 2024

Make both mergeConfig and extractConfigByNamespace throw if they try to merge a non-object value on a key that already stores an object value.

The errors would let users know that they are (likely unintentionally) passing a property or data-attribute that conflicts with deeper values (for example setting data-i18n which would default with the configuration for the default message).

While this sounds a handy information:

Implementation note

Was a bit shy to pass componentName to the helper functions, but that could be an alternative solution that avoids catching and re-throwing the errors. Doesn't feel super tidy from a separation of responsibility point of view.

@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-4809 February 29, 2024 19:34 Inactive
Copy link

📋 Stats

File sizes

File Size
dist/govuk-frontend-development.min.css 112.47 KiB
dist/govuk-frontend-development.min.js 40.45 KiB
packages/govuk-frontend/dist/govuk/all.bundle.js 82.88 KiB
packages/govuk-frontend/dist/govuk/all.bundle.mjs 77.85 KiB
packages/govuk-frontend/dist/govuk/all.mjs 3.86 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend-component.mjs 359 B
packages/govuk-frontend/dist/govuk/govuk-frontend.min.css 112.46 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend.min.js 40.44 KiB
packages/govuk-frontend/dist/govuk/i18n.mjs 5.55 KiB

Modules

File Size (bundled) Size (minified)
all.mjs 73.78 KiB 38.55 KiB
accordion.mjs 23.53 KiB 13.36 KiB
button.mjs 6.82 KiB 3.2 KiB
character-count.mjs 23.1 KiB 10.36 KiB
checkboxes.mjs 5.83 KiB 2.83 KiB
error-summary.mjs 8.71 KiB 3.97 KiB
exit-this-page.mjs 17.97 KiB 9.8 KiB
header.mjs 4.46 KiB 2.6 KiB
notification-banner.mjs 7.09 KiB 3.14 KiB
radios.mjs 4.83 KiB 2.38 KiB
skip-link.mjs 4.39 KiB 2.18 KiB
tabs.mjs 10.16 KiB 6.13 KiB

View stats and visualisations on the review app


Action run for 6c63991

Copy link

JavaScript changes to npm package

diff --git a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
index 85682bbeb..5ab2300b5 100644
--- a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
+++ b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
@@ -17,15 +17,20 @@ function normaliseString(e, t) {
     return i
 }
 
-function mergeConfigs(...e) {
-    const t = {};
-    for (const n of e)
-        for (const e of Object.keys(n)) {
-            const i = t[e],
-                s = n[e];
-            isObject(i) && !isObject(s) || (isObject(i) && isObject(s) ? t[e] = mergeConfigs(i, s) : t[e] = s)
+function mergeConfigs(e, {
+    path: t = []
+} = {}) {
+    const n = {};
+    for (const i of e)
+        for (const e of Object.keys(i)) {
+            const s = n[e],
+                o = i[e];
+            if (isObject(s) && !isObject(o)) throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...t,e].join(".")}\``);
+            isObject(s) && isObject(o) ? n[e] = mergeConfigs([s, o], {
+                path: [...t, e]
+            }) : n[e] = o
         }
-    return t
+    return n
 }
 
 function extractConfigByNamespace(e, t) {
@@ -35,12 +40,16 @@ function extractConfigByNamespace(e, t) {
         const i = e[s],
             o = s.split(".");
         if (o[0] === t) {
-            o.shift();
+            if (o.shift(), !o.length) throw new TypeError(`\`data-${t}\` cannot exist on its own`);
             let e = n;
-            for (const t of o) {
-                isObject(e[t]) || (e[t] = {});
-                const n = e[t];
-                t === o[o.length - 1] && (e[t] = normaliseString(i)), "object" == typeof n && (e = n)
+            const s = [];
+            for (const n of o) {
+                if (s.push(n), !isObject(e[n])) {
+                    if (n in e) throw new TypeError(`\`data-${t}.${o.join(".")}\` cannot exist if \`data-${t}.${s.join(".")}\` is present`);
+                    e[n] = {}
+                }
+                const r = e[n];
+                n === o[o.length - 1] && (e[n] = normaliseString(i)), "object" == typeof r && (e = r)
             }
         }
     }
@@ -222,7 +231,13 @@ class Accordion extends GOVUKFrontendComponent {
             element: t,
             identifier: "Root element (`$module`)"
         });
-        this.$module = t, this.config = mergeConfigs(Accordion.defaults, n, normaliseDataset(t.dataset, Accordion.schema)), this.i18n = new I18n(this.config.i18n);
+        this.$module = t;
+        try {
+            this.config = mergeConfigs([Accordion.defaults, n, normaliseDataset(t.dataset, Accordion.schema)])
+        } catch (o) {
+            throw new ConfigError(`Accordion: ${o instanceof Error?o.message:String(o)}`)
+        }
+        this.i18n = new I18n(this.config.i18n);
         const i = this.$module.querySelectorAll(`.${this.sectionClass}`);
         if (!i.length) throw new ElementError({
             componentName: "Accordion",
@@ -391,7 +406,13 @@ class Button extends GOVUKFrontendComponent {
             element: e,
             identifier: "Root element (`$module`)"
         });
-        this.$module = e, this.config = mergeConfigs(Button.defaults, t, normaliseDataset(e.dataset, Button.schema)), this.$module.addEventListener("keydown", (e => this.handleKeyDown(e))), this.$module.addEventListener("click", (e => this.debounce(e)))
+        this.$module = e;
+        try {
+            this.config = mergeConfigs([Button.defaults, t, normaliseDataset(e.dataset, Button.schema)])
+        } catch (n) {
+            throw new ConfigError(`Button: ${n instanceof Error?n.message:String(n)}`)
+        }
+        this.$module.addEventListener("keydown", (e => this.handleKeyDown(e))), this.$module.addEventListener("click", (e => this.debounce(e)))
     }
     handleKeyDown(e) {
         const t = e.target;
@@ -437,7 +458,12 @@ class CharacterCount extends GOVUKFrontendComponent {
         ("maxwords" in o || "maxlength" in o) && (r = {
             maxlength: void 0,
             maxwords: void 0
-        }), this.config = mergeConfigs(CharacterCount.defaults, t, r, o);
+        });
+        try {
+            this.config = mergeConfigs([CharacterCount.defaults, t, r, o])
+        } catch (d) {
+            throw new ConfigError(`Character count: ${d instanceof Error?d.message:String(d)}`)
+        }
         const a = function(e, t) {
             const n = [];
             for (const [i, s] of Object.entries(e)) {
@@ -632,7 +658,13 @@ class ErrorSummary extends GOVUKFrontendComponent {
             element: e,
             identifier: "Root element (`$module`)"
         });
-        this.$module = e, this.config = mergeConfigs(ErrorSummary.defaults, t, normaliseDataset(e.dataset, ErrorSummary.schema)), this.config.disableAutoFocus || setFocus(this.$module), this.$module.addEventListener("click", (e => this.handleClick(e)))
+        this.$module = e;
+        try {
+            this.config = mergeConfigs([ErrorSummary.defaults, t, normaliseDataset(e.dataset, ErrorSummary.schema)])
+        } catch (n) {
+            throw new ConfigError(`Error summary: ${n instanceof Error?n.message:String(n)}`)
+        }
+        this.config.disableAutoFocus || setFocus(this.$module), this.$module.addEventListener("click", (e => this.handleClick(e)))
     }
     handleClick(e) {
         const t = e.target;
@@ -690,7 +722,12 @@ class ExitThisPage extends GOVUKFrontendComponent {
             expectedType: "HTMLAnchorElement",
             identifier: "Button (`.govuk-exit-this-page__button`)"
         });
-        this.config = mergeConfigs(ExitThisPage.defaults, t, normaliseDataset(e.dataset, ExitThisPage.schema)), this.i18n = new I18n(this.config.i18n), this.$module = e, this.$button = n;
+        try {
+            this.config = mergeConfigs([ExitThisPage.defaults, t, normaliseDataset(e.dataset, ExitThisPage.schema)])
+        } catch (s) {
+            throw new ConfigError(`Exit this page: ${s instanceof Error?s.message:String(s)}`)
+        }
+        this.i18n = new I18n(this.config.i18n), this.$module = e, this.$button = n;
         const i = document.querySelector(".govuk-js-exit-this-page-skiplink");
         i instanceof HTMLAnchorElement && (this.$skiplinkButton = i), this.buildIndicator(), this.initUpdateSpan(), this.initButtonClickHandler(), "govukFrontendExitThisPageKeypress" in document.body.dataset || (document.addEventListener("keyup", this.handleKeypress.bind(this), !0), document.body.dataset.govukFrontendExitThisPageKeypress = "true"), window.addEventListener("pageshow", this.resetPage.bind(this))
     }
@@ -799,7 +836,13 @@ class NotificationBanner extends GOVUKFrontendComponent {
             element: e,
             identifier: "Root element (`$module`)"
         });
-        this.$module = e, this.config = mergeConfigs(NotificationBanner.defaults, t, normaliseDataset(e.dataset, NotificationBanner.schema)), "alert" !== this.$module.getAttribute("role") || this.config.disableAutoFocus || setFocus(this.$module)
+        this.$module = e;
+        try {
+            this.config = mergeConfigs([NotificationBanner.defaults, t, normaliseDataset(e.dataset, NotificationBanner.schema)])
+        } catch (n) {
+            throw new ConfigError(`Notification banner: ${n instanceof Error?n.message:String(n)}`)
+        }
+        "alert" !== this.$module.getAttribute("role") || this.config.disableAutoFocus || setFocus(this.$module)
     }
 }
 NotificationBanner.moduleName = "govuk-notification-banner", NotificationBanner.defaults = Object.freeze({

Action run for 6c63991

Copy link

Other changes to npm package

diff --git a/packages/govuk-frontend/dist/govuk/all.bundle.js b/packages/govuk-frontend/dist/govuk/all.bundle.js
index 5a96ed573..c1f832167 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.js
@@ -39,19 +39,23 @@
    * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
    */
 
-  function mergeConfigs(...configObjects) {
+  function mergeConfigs(configObjects, {
+    path = []
+  } = {}) {
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
       for (const key of Object.keys(configObject)) {
         const option = formattedConfigObject[key];
         const override = configObject[key];
-        const nonObjectOverridesObject = isObject(option) && !isObject(override);
-        if (!nonObjectOverridesObject) {
-          if (isObject(option) && isObject(override)) {
-            formattedConfigObject[key] = mergeConfigs(option, override);
-          } else {
-            formattedConfigObject[key] = override;
-          }
+        if (isObject(option) && !isObject(override)) {
+          throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+        }
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs([option, override], {
+            path: [...path, key]
+          });
+        } else {
+          formattedConfigObject[key] = override;
         }
       }
     }
@@ -65,9 +69,17 @@
       const keyParts = key.split('.');
       if (keyParts[0] === namespace) {
         keyParts.shift();
+        if (!keyParts.length) {
+          throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+        }
         let current = newObject;
+        const path = [];
         for (const name of keyParts) {
+          path.push(name);
           if (!isObject(current[name])) {
+            if (name in current) {
+              throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+            }
             current[name] = {};
           }
           const next = current[name];
@@ -492,7 +504,11 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset($module.dataset, Accordion.schema));
+      try {
+        this.config = mergeConfigs([Accordion.defaults, config, normaliseDataset($module.dataset, Accordion.schema)]);
+      } catch (error) {
+        throw new ConfigError(`Accordion: ${error instanceof Error ? error.message : String(error)}`);
+      }
       this.i18n = new I18n(this.config.i18n);
       const $sections = this.$module.querySelectorAll(`.${this.sectionClass}`);
       if (!$sections.length) {
@@ -822,7 +838,11 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(Button.defaults, config, normaliseDataset($module.dataset, Button.schema));
+      try {
+        this.config = mergeConfigs([Button.defaults, config, normaliseDataset($module.dataset, Button.schema)]);
+      } catch (error) {
+        throw new ConfigError(`Button: ${error instanceof Error ? error.message : String(error)}`);
+      }
       this.$module.addEventListener('keydown', event => this.handleKeyDown(event));
       this.$module.addEventListener('click', event => this.debounce(event));
     }
@@ -932,7 +952,11 @@
           maxwords: undefined
         };
       }
-      this.config = mergeConfigs(CharacterCount.defaults, config, configOverrides, datasetConfig);
+      try {
+        this.config = mergeConfigs([CharacterCount.defaults, config, configOverrides, datasetConfig]);
+      } catch (error) {
+        throw new ConfigError(`Character count: ${error instanceof Error ? error.message : String(error)}`);
+      }
       const errors = validateConfig(CharacterCount.schema, this.config);
       if (errors[0]) {
         throw new ConfigError(`Character count: ${errors[0]}`);
@@ -1313,7 +1337,11 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset($module.dataset, ErrorSummary.schema));
+      try {
+        this.config = mergeConfigs([ErrorSummary.defaults, config, normaliseDataset($module.dataset, ErrorSummary.schema)]);
+      } catch (error) {
+        throw new ConfigError(`Error summary: ${error instanceof Error ? error.message : String(error)}`);
+      }
       if (!this.config.disableAutoFocus) {
         setFocus(this.$module);
       }
@@ -1435,7 +1463,11 @@
           identifier: 'Button (`.govuk-exit-this-page__button`)'
         });
       }
-      this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset($module.dataset, ExitThisPage.schema));
+      try {
+        this.config = mergeConfigs([ExitThisPage.defaults, config, normaliseDataset($module.dataset, ExitThisPage.schema)]);
+      } catch (error) {
+        throw new ConfigError(`Exit this page: ${error instanceof Error ? error.message : String(error)}`);
+      }
       this.i18n = new I18n(this.config.i18n);
       this.$module = $module;
       this.$button = $button;
@@ -1734,7 +1766,11 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset($module.dataset, NotificationBanner.schema));
+      try {
+        this.config = mergeConfigs([NotificationBanner.defaults, config, normaliseDataset($module.dataset, NotificationBanner.schema)]);
+      } catch (error) {
+        throw new ConfigError(`Notification banner: ${error instanceof Error ? error.message : String(error)}`);
+      }
       if (this.$module.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
         setFocus(this.$module);
       }
diff --git a/packages/govuk-frontend/dist/govuk/all.bundle.mjs b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
index 232be4bb5..c7b316ddd 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
@@ -33,19 +33,23 @@ function normaliseString(value, options) {
  * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
  */
 
-function mergeConfigs(...configObjects) {
+function mergeConfigs(configObjects, {
+  path = []
+} = {}) {
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
     for (const key of Object.keys(configObject)) {
       const option = formattedConfigObject[key];
       const override = configObject[key];
-      const nonObjectOverridesObject = isObject(option) && !isObject(override);
-      if (!nonObjectOverridesObject) {
-        if (isObject(option) && isObject(override)) {
-          formattedConfigObject[key] = mergeConfigs(option, override);
-        } else {
-          formattedConfigObject[key] = override;
-        }
+      if (isObject(option) && !isObject(override)) {
+        throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+      }
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs([option, override], {
+          path: [...path, key]
+        });
+      } else {
+        formattedConfigObject[key] = override;
       }
     }
   }
@@ -59,9 +63,17 @@ function extractConfigByNamespace(dataset, namespace) {
     const keyParts = key.split('.');
     if (keyParts[0] === namespace) {
       keyParts.shift();
+      if (!keyParts.length) {
+        throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+      }
       let current = newObject;
+      const path = [];
       for (const name of keyParts) {
+        path.push(name);
         if (!isObject(current[name])) {
+          if (name in current) {
+            throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+          }
           current[name] = {};
         }
         const next = current[name];
@@ -486,7 +498,11 @@ class Accordion extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset($module.dataset, Accordion.schema));
+    try {
+      this.config = mergeConfigs([Accordion.defaults, config, normaliseDataset($module.dataset, Accordion.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Accordion: ${error instanceof Error ? error.message : String(error)}`);
+    }
     this.i18n = new I18n(this.config.i18n);
     const $sections = this.$module.querySelectorAll(`.${this.sectionClass}`);
     if (!$sections.length) {
@@ -816,7 +832,11 @@ class Button extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(Button.defaults, config, normaliseDataset($module.dataset, Button.schema));
+    try {
+      this.config = mergeConfigs([Button.defaults, config, normaliseDataset($module.dataset, Button.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Button: ${error instanceof Error ? error.message : String(error)}`);
+    }
     this.$module.addEventListener('keydown', event => this.handleKeyDown(event));
     this.$module.addEventListener('click', event => this.debounce(event));
   }
@@ -926,7 +946,11 @@ class CharacterCount extends GOVUKFrontendComponent {
         maxwords: undefined
       };
     }
-    this.config = mergeConfigs(CharacterCount.defaults, config, configOverrides, datasetConfig);
+    try {
+      this.config = mergeConfigs([CharacterCount.defaults, config, configOverrides, datasetConfig]);
+    } catch (error) {
+      throw new ConfigError(`Character count: ${error instanceof Error ? error.message : String(error)}`);
+    }
     const errors = validateConfig(CharacterCount.schema, this.config);
     if (errors[0]) {
       throw new ConfigError(`Character count: ${errors[0]}`);
@@ -1307,7 +1331,11 @@ class ErrorSummary extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset($module.dataset, ErrorSummary.schema));
+    try {
+      this.config = mergeConfigs([ErrorSummary.defaults, config, normaliseDataset($module.dataset, ErrorSummary.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Error summary: ${error instanceof Error ? error.message : String(error)}`);
+    }
     if (!this.config.disableAutoFocus) {
       setFocus(this.$module);
     }
@@ -1429,7 +1457,11 @@ class ExitThisPage extends GOVUKFrontendComponent {
         identifier: 'Button (`.govuk-exit-this-page__button`)'
       });
     }
-    this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset($module.dataset, ExitThisPage.schema));
+    try {
+      this.config = mergeConfigs([ExitThisPage.defaults, config, normaliseDataset($module.dataset, ExitThisPage.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Exit this page: ${error instanceof Error ? error.message : String(error)}`);
+    }
     this.i18n = new I18n(this.config.i18n);
     this.$module = $module;
     this.$button = $button;
@@ -1728,7 +1760,11 @@ class NotificationBanner extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset($module.dataset, NotificationBanner.schema));
+    try {
+      this.config = mergeConfigs([NotificationBanner.defaults, config, normaliseDataset($module.dataset, NotificationBanner.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Notification banner: ${error instanceof Error ? error.message : String(error)}`);
+    }
     if (this.$module.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
       setFocus(this.$module);
     }
diff --git a/packages/govuk-frontend/dist/govuk/common/index.mjs b/packages/govuk-frontend/dist/govuk/common/index.mjs
index 9e1ecab6d..d6babf353 100644
--- a/packages/govuk-frontend/dist/govuk/common/index.mjs
+++ b/packages/govuk-frontend/dist/govuk/common/index.mjs
@@ -1,18 +1,22 @@
 import { normaliseString } from './normalise-string.mjs';
 
-function mergeConfigs(...configObjects) {
+function mergeConfigs(configObjects, {
+  path = []
+} = {}) {
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
     for (const key of Object.keys(configObject)) {
       const option = formattedConfigObject[key];
       const override = configObject[key];
-      const nonObjectOverridesObject = isObject(option) && !isObject(override);
-      if (!nonObjectOverridesObject) {
-        if (isObject(option) && isObject(override)) {
-          formattedConfigObject[key] = mergeConfigs(option, override);
-        } else {
-          formattedConfigObject[key] = override;
-        }
+      if (isObject(option) && !isObject(override)) {
+        throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+      }
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs([option, override], {
+          path: [...path, key]
+        });
+      } else {
+        formattedConfigObject[key] = override;
       }
     }
   }
@@ -26,9 +30,17 @@ function extractConfigByNamespace(dataset, namespace) {
     const keyParts = key.split('.');
     if (keyParts[0] === namespace) {
       keyParts.shift();
+      if (!keyParts.length) {
+        throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+      }
       let current = newObject;
+      const path = [];
       for (const name of keyParts) {
+        path.push(name);
         if (!isObject(current[name])) {
+          if (name in current) {
+            throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+          }
           current[name] = {};
         }
         const next = current[name];
diff --git a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.js b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.js
index 595c58e08..ac102fefd 100644
--- a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.js
@@ -37,19 +37,23 @@
    * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
    */
 
-  function mergeConfigs(...configObjects) {
+  function mergeConfigs(configObjects, {
+    path = []
+  } = {}) {
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
       for (const key of Object.keys(configObject)) {
         const option = formattedConfigObject[key];
         const override = configObject[key];
-        const nonObjectOverridesObject = isObject(option) && !isObject(override);
-        if (!nonObjectOverridesObject) {
-          if (isObject(option) && isObject(override)) {
-            formattedConfigObject[key] = mergeConfigs(option, override);
-          } else {
-            formattedConfigObject[key] = override;
-          }
+        if (isObject(option) && !isObject(override)) {
+          throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+        }
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs([option, override], {
+            path: [...path, key]
+          });
+        } else {
+          formattedConfigObject[key] = override;
         }
       }
     }
@@ -63,9 +67,17 @@
       const keyParts = key.split('.');
       if (keyParts[0] === namespace) {
         keyParts.shift();
+        if (!keyParts.length) {
+          throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+        }
         let current = newObject;
+        const path = [];
         for (const name of keyParts) {
+          path.push(name);
           if (!isObject(current[name])) {
+            if (name in current) {
+              throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+            }
             current[name] = {};
           }
           const next = current[name];
@@ -148,6 +160,12 @@
       this.name = 'SupportError';
     }
   }
+  class ConfigError extends GOVUKFrontendError {
+    constructor(...args) {
+      super(...args);
+      this.name = 'ConfigError';
+    }
+  }
   class ElementError extends GOVUKFrontendError {
     constructor(messageOrOptions) {
       let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
@@ -426,7 +444,11 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset($module.dataset, Accordion.schema));
+      try {
+        this.config = mergeConfigs([Accordion.defaults, config, normaliseDataset($module.dataset, Accordion.schema)]);
+      } catch (error) {
+        throw new ConfigError(`Accordion: ${error instanceof Error ? error.message : String(error)}`);
+      }
       this.i18n = new I18n(this.config.i18n);
       const $sections = this.$module.querySelectorAll(`.${this.sectionClass}`);
       if (!$sections.length) {
diff --git a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.mjs
index 6940ede63..eb4a6a71c 100644
--- a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.mjs
@@ -31,19 +31,23 @@ function normaliseString(value, options) {
  * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
  */
 
-function mergeConfigs(...configObjects) {
+function mergeConfigs(configObjects, {
+  path = []
+} = {}) {
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
     for (const key of Object.keys(configObject)) {
       const option = formattedConfigObject[key];
       const override = configObject[key];
-      const nonObjectOverridesObject = isObject(option) && !isObject(override);
-      if (!nonObjectOverridesObject) {
-        if (isObject(option) && isObject(override)) {
-          formattedConfigObject[key] = mergeConfigs(option, override);
-        } else {
-          formattedConfigObject[key] = override;
-        }
+      if (isObject(option) && !isObject(override)) {
+        throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+      }
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs([option, override], {
+          path: [...path, key]
+        });
+      } else {
+        formattedConfigObject[key] = override;
       }
     }
   }
@@ -57,9 +61,17 @@ function extractConfigByNamespace(dataset, namespace) {
     const keyParts = key.split('.');
     if (keyParts[0] === namespace) {
       keyParts.shift();
+      if (!keyParts.length) {
+        throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+      }
       let current = newObject;
+      const path = [];
       for (const name of keyParts) {
+        path.push(name);
         if (!isObject(current[name])) {
+          if (name in current) {
+            throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+          }
           current[name] = {};
         }
         const next = current[name];
@@ -142,6 +154,12 @@ class SupportError extends GOVUKFrontendError {
     this.name = 'SupportError';
   }
 }
+class ConfigError extends GOVUKFrontendError {
+  constructor(...args) {
+    super(...args);
+    this.name = 'ConfigError';
+  }
+}
 class ElementError extends GOVUKFrontendError {
   constructor(messageOrOptions) {
     let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
@@ -420,7 +438,11 @@ class Accordion extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset($module.dataset, Accordion.schema));
+    try {
+      this.config = mergeConfigs([Accordion.defaults, config, normaliseDataset($module.dataset, Accordion.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Accordion: ${error instanceof Error ? error.message : String(error)}`);
+    }
     this.i18n = new I18n(this.config.i18n);
     const $sections = this.$module.querySelectorAll(`.${this.sectionClass}`);
     if (!$sections.length) {
diff --git a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.mjs b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.mjs
index 33e3201f9..e8b287b3b 100644
--- a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.mjs
@@ -1,6 +1,6 @@
 import { mergeConfigs } from '../../common/index.mjs';
 import { normaliseDataset } from '../../common/normalise-dataset.mjs';
-import { ElementError } from '../../errors/index.mjs';
+import { ElementError, ConfigError } from '../../errors/index.mjs';
 import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs';
 import { I18n } from '../../i18n.mjs';
 
@@ -60,7 +60,11 @@ class Accordion extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset($module.dataset, Accordion.schema));
+    try {
+      this.config = mergeConfigs([Accordion.defaults, config, normaliseDataset($module.dataset, Accordion.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Accordion: ${error instanceof Error ? error.message : String(error)}`);
+    }
     this.i18n = new I18n(this.config.i18n);
     const $sections = this.$module.querySelectorAll(`.${this.sectionClass}`);
     if (!$sections.length) {
diff --git a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js
index 19aa821bd..d9b3c9c65 100644
--- a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js
@@ -37,19 +37,23 @@
    * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
    */
 
-  function mergeConfigs(...configObjects) {
+  function mergeConfigs(configObjects, {
+    path = []
+  } = {}) {
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
       for (const key of Object.keys(configObject)) {
         const option = formattedConfigObject[key];
         const override = configObject[key];
-        const nonObjectOverridesObject = isObject(option) && !isObject(override);
-        if (!nonObjectOverridesObject) {
-          if (isObject(option) && isObject(override)) {
-            formattedConfigObject[key] = mergeConfigs(option, override);
-          } else {
-            formattedConfigObject[key] = override;
-          }
+        if (isObject(option) && !isObject(override)) {
+          throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+        }
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs([option, override], {
+            path: [...path, key]
+          });
+        } else {
+          formattedConfigObject[key] = override;
         }
       }
     }
@@ -63,9 +67,17 @@
       const keyParts = key.split('.');
       if (keyParts[0] === namespace) {
         keyParts.shift();
+        if (!keyParts.length) {
+          throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+        }
         let current = newObject;
+        const path = [];
         for (const name of keyParts) {
+          path.push(name);
           if (!isObject(current[name])) {
+            if (name in current) {
+              throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+            }
             current[name] = {};
           }
           const next = current[name];
@@ -148,6 +160,12 @@
       this.name = 'SupportError';
     }
   }
+  class ConfigError extends GOVUKFrontendError {
+    constructor(...args) {
+      super(...args);
+      this.name = 'ConfigError';
+    }
+  }
   class ElementError extends GOVUKFrontendError {
     constructor(messageOrOptions) {
       let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
@@ -203,7 +221,11 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(Button.defaults, config, normaliseDataset($module.dataset, Button.schema));
+      try {
+        this.config = mergeConfigs([Button.defaults, config, normaliseDataset($module.dataset, Button.schema)]);
+      } catch (error) {
+        throw new ConfigError(`Button: ${error instanceof Error ? error.message : String(error)}`);
+      }
       this.$module.addEventListener('keydown', event => this.handleKeyDown(event));
       this.$module.addEventListener('click', event => this.debounce(event));
     }
diff --git a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs
index c5f81233b..8448bade6 100644
--- a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs
@@ -31,19 +31,23 @@ function normaliseString(value, options) {
  * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
  */
 
-function mergeConfigs(...configObjects) {
+function mergeConfigs(configObjects, {
+  path = []
+} = {}) {
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
     for (const key of Object.keys(configObject)) {
       const option = formattedConfigObject[key];
       const override = configObject[key];
-      const nonObjectOverridesObject = isObject(option) && !isObject(override);
-      if (!nonObjectOverridesObject) {
-        if (isObject(option) && isObject(override)) {
-          formattedConfigObject[key] = mergeConfigs(option, override);
-        } else {
-          formattedConfigObject[key] = override;
-        }
+      if (isObject(option) && !isObject(override)) {
+        throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+      }
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs([option, override], {
+          path: [...path, key]
+        });
+      } else {
+        formattedConfigObject[key] = override;
       }
     }
   }
@@ -57,9 +61,17 @@ function extractConfigByNamespace(dataset, namespace) {
     const keyParts = key.split('.');
     if (keyParts[0] === namespace) {
       keyParts.shift();
+      if (!keyParts.length) {
+        throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+      }
       let current = newObject;
+      const path = [];
       for (const name of keyParts) {
+        path.push(name);
         if (!isObject(current[name])) {
+          if (name in current) {
+            throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+          }
           current[name] = {};
         }
         const next = current[name];
@@ -142,6 +154,12 @@ class SupportError extends GOVUKFrontendError {
     this.name = 'SupportError';
   }
 }
+class ConfigError extends GOVUKFrontendError {
+  constructor(...args) {
+    super(...args);
+    this.name = 'ConfigError';
+  }
+}
 class ElementError extends GOVUKFrontendError {
   constructor(messageOrOptions) {
     let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
@@ -197,7 +215,11 @@ class Button extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(Button.defaults, config, normaliseDataset($module.dataset, Button.schema));
+    try {
+      this.config = mergeConfigs([Button.defaults, config, normaliseDataset($module.dataset, Button.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Button: ${error instanceof Error ? error.message : String(error)}`);
+    }
     this.$module.addEventListener('keydown', event => this.handleKeyDown(event));
     this.$module.addEventListener('click', event => this.debounce(event));
   }
diff --git a/packages/govuk-frontend/dist/govuk/components/button/button.mjs b/packages/govuk-frontend/dist/govuk/components/button/button.mjs
index 7907e2411..a31c2380b 100644
--- a/packages/govuk-frontend/dist/govuk/components/button/button.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/button/button.mjs
@@ -1,6 +1,6 @@
 import { mergeConfigs } from '../../common/index.mjs';
 import { normaliseDataset } from '../../common/normalise-dataset.mjs';
-import { ElementError } from '../../errors/index.mjs';
+import { ElementError, ConfigError } from '../../errors/index.mjs';
 import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs';
 
 const KEY_SPACE = 32;
@@ -29,7 +29,11 @@ class Button extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(Button.defaults, config, normaliseDataset($module.dataset, Button.schema));
+    try {
+      this.config = mergeConfigs([Button.defaults, config, normaliseDataset($module.dataset, Button.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Button: ${error instanceof Error ? error.message : String(error)}`);
+    }
     this.$module.addEventListener('keydown', event => this.handleKeyDown(event));
     this.$module.addEventListener('click', event => this.debounce(event));
   }
diff --git a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.js b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.js
index 8a9a10c54..efe221585 100644
--- a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.js
@@ -42,19 +42,23 @@
    * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
    */
 
-  function mergeConfigs(...configObjects) {
+  function mergeConfigs(configObjects, {
+    path = []
+  } = {}) {
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
       for (const key of Object.keys(configObject)) {
         const option = formattedConfigObject[key];
         const override = configObject[key];
-        const nonObjectOverridesObject = isObject(option) && !isObject(override);
-        if (!nonObjectOverridesObject) {
-          if (isObject(option) && isObject(override)) {
-            formattedConfigObject[key] = mergeConfigs(option, override);
-          } else {
-            formattedConfigObject[key] = override;
-          }
+        if (isObject(option) && !isObject(override)) {
+          throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+        }
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs([option, override], {
+            path: [...path, key]
+          });
+        } else {
+          formattedConfigObject[key] = override;
         }
       }
     }
@@ -68,9 +72,17 @@
       const keyParts = key.split('.');
       if (keyParts[0] === namespace) {
         keyParts.shift();
+        if (!keyParts.length) {
+          throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+        }
         let current = newObject;
+        const path = [];
         for (const name of keyParts) {
+          path.push(name);
           if (!isObject(current[name])) {
+            if (name in current) {
+              throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+            }
             current[name] = {};
           }
           const next = current[name];
@@ -455,7 +467,11 @@
           maxwords: undefined
         };
       }
-      this.config = mergeConfigs(CharacterCount.defaults, config, configOverrides, datasetConfig);
+      try {
+        this.config = mergeConfigs([CharacterCount.defaults, config, configOverrides, datasetConfig]);
+      } catch (error) {
+        throw new ConfigError(`Character count: ${error instanceof Error ? error.message : String(error)}`);
+      }
       const errors = validateConfig(CharacterCount.schema, this.config);
       if (errors[0]) {
         throw new ConfigError(`Character count: ${errors[0]}`);
diff --git a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.mjs
index 221daad80..ba708a86e 100644
--- a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.mjs
@@ -36,19 +36,23 @@ function normaliseString(value, options) {
  * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
  */
 
-function mergeConfigs(...configObjects) {
+function mergeConfigs(configObjects, {
+  path = []
+} = {}) {
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
     for (const key of Object.keys(configObject)) {
       const option = formattedConfigObject[key];
       const override = configObject[key];
-      const nonObjectOverridesObject = isObject(option) && !isObject(override);
-      if (!nonObjectOverridesObject) {
-        if (isObject(option) && isObject(override)) {
-          formattedConfigObject[key] = mergeConfigs(option, override);
-        } else {
-          formattedConfigObject[key] = override;
-        }
+      if (isObject(option) && !isObject(override)) {
+        throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+      }
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs([option, override], {
+          path: [...path, key]
+        });
+      } else {
+        formattedConfigObject[key] = override;
       }
     }
   }
@@ -62,9 +66,17 @@ function extractConfigByNamespace(dataset, namespace) {
     const keyParts = key.split('.');
     if (keyParts[0] === namespace) {
       keyParts.shift();
+      if (!keyParts.length) {
+        throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+      }
       let current = newObject;
+      const path = [];
       for (const name of keyParts) {
+        path.push(name);
         if (!isObject(current[name])) {
+          if (name in current) {
+            throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+          }
           current[name] = {};
         }
         const next = current[name];
@@ -449,7 +461,11 @@ class CharacterCount extends GOVUKFrontendComponent {
         maxwords: undefined
       };
     }
-    this.config = mergeConfigs(CharacterCount.defaults, config, configOverrides, datasetConfig);
+    try {
+      this.config = mergeConfigs([CharacterCount.defaults, config, configOverrides, datasetConfig]);
+    } catch (error) {
+      throw new ConfigError(`Character count: ${error instanceof Error ? error.message : String(error)}`);
+    }
     const errors = validateConfig(CharacterCount.schema, this.config);
     if (errors[0]) {
       throw new ConfigError(`Character count: ${errors[0]}`);
diff --git a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.mjs b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.mjs
index d33d26cc7..376efdcb5 100644
--- a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.mjs
@@ -59,7 +59,11 @@ class CharacterCount extends GOVUKFrontendComponent {
         maxwords: undefined
       };
     }
-    this.config = mergeConfigs(CharacterCount.defaults, config, configOverrides, datasetConfig);
+    try {
+      this.config = mergeConfigs([CharacterCount.defaults, config, configOverrides, datasetConfig]);
+    } catch (error) {
+      throw new ConfigError(`Character count: ${error instanceof Error ? error.message : String(error)}`);
+    }
     const errors = validateConfig(CharacterCount.schema, this.config);
     if (errors[0]) {
       throw new ConfigError(`Character count: ${errors[0]}`);
diff --git a/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.js b/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.js
index a7893611f..971b6d98a 100644
--- a/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.js
@@ -37,19 +37,23 @@
    * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
    */
 
-  function mergeConfigs(...configObjects) {
+  function mergeConfigs(configObjects, {
+    path = []
+  } = {}) {
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
       for (const key of Object.keys(configObject)) {
         const option = formattedConfigObject[key];
         const override = configObject[key];
-        const nonObjectOverridesObject = isObject(option) && !isObject(override);
-        if (!nonObjectOverridesObject) {
-          if (isObject(option) && isObject(override)) {
-            formattedConfigObject[key] = mergeConfigs(option, override);
-          } else {
-            formattedConfigObject[key] = override;
-          }
+        if (isObject(option) && !isObject(override)) {
+          throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+        }
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs([option, override], {
+            path: [...path, key]
+          });
+        } else {
+          formattedConfigObject[key] = override;
         }
       }
     }
@@ -63,9 +67,17 @@
       const keyParts = key.split('.');
       if (keyParts[0] === namespace) {
         keyParts.shift();
+        if (!keyParts.length) {
+          throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+        }
         let current = newObject;
+        const path = [];
         for (const name of keyParts) {
+          path.push(name);
           if (!isObject(current[name])) {
+            if (name in current) {
+              throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+            }
             current[name] = {};
           }
           const next = current[name];
@@ -178,6 +190,12 @@
       this.name = 'SupportError';
     }
   }
+  class ConfigError extends GOVUKFrontendError {
+    constructor(...args) {
+      super(...args);
+      this.name = 'ConfigError';
+    }
+  }
   class ElementError extends GOVUKFrontendError {
     constructor(messageOrOptions) {
       let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
@@ -232,7 +250,11 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset($module.dataset, ErrorSummary.schema));
+      try {
+        this.config = mergeConfigs([ErrorSummary.defaults, config, normaliseDataset($module.dataset, ErrorSummary.schema)]);
+      } catch (error) {
+        throw new ConfigError(`Error summary: ${error instanceof Error ? error.message : String(error)}`);
+      }
       if (!this.config.disableAutoFocus) {
         setFocus(this.$module);
       }
diff --git a/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.mjs
index b027bb049..a64e7fb0a 100644
--- a/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.mjs
@@ -31,19 +31,23 @@ function normaliseString(value, options) {
  * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
  */
 
-function mergeConfigs(...configObjects) {
+function mergeConfigs(configObjects, {
+  path = []
+} = {}) {
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
     for (const key of Object.keys(configObject)) {
       const option = formattedConfigObject[key];
       const override = configObject[key];
-      const nonObjectOverridesObject = isObject(option) && !isObject(override);
-      if (!nonObjectOverridesObject) {
-        if (isObject(option) && isObject(override)) {
-          formattedConfigObject[key] = mergeConfigs(option, override);
-        } else {
-          formattedConfigObject[key] = override;
-        }
+      if (isObject(option) && !isObject(override)) {
+        throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+      }
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs([option, override], {
+          path: [...path, key]
+        });
+      } else {
+        formattedConfigObject[key] = override;
       }
     }
   }
@@ -57,9 +61,17 @@ function extractConfigByNamespace(dataset, namespace) {
     const keyParts = key.split('.');
     if (keyParts[0] === namespace) {
       keyParts.shift();
+      if (!keyParts.length) {
+        throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+      }
       let current = newObject;
+      const path = [];
       for (const name of keyParts) {
+        path.push(name);
         if (!isObject(current[name])) {
+          if (name in current) {
+            throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+          }
           current[name] = {};
         }
         const next = current[name];
@@ -172,6 +184,12 @@ class SupportError extends GOVUKFrontendError {
     this.name = 'SupportError';
   }
 }
+class ConfigError extends GOVUKFrontendError {
+  constructor(...args) {
+    super(...args);
+    this.name = 'ConfigError';
+  }
+}
 class ElementError extends GOVUKFrontendError {
   constructor(messageOrOptions) {
     let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
@@ -226,7 +244,11 @@ class ErrorSummary extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset($module.dataset, ErrorSummary.schema));
+    try {
+      this.config = mergeConfigs([ErrorSummary.defaults, config, normaliseDataset($module.dataset, ErrorSummary.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Error summary: ${error instanceof Error ? error.message : String(error)}`);
+    }
     if (!this.config.disableAutoFocus) {
       setFocus(this.$module);
     }
diff --git a/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.mjs b/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.mjs
index 75bd427cd..bf138be77 100644
--- a/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.mjs
@@ -1,6 +1,6 @@
 import { mergeConfigs, setFocus, getFragmentFromUrl } from '../../common/index.mjs';
 import { normaliseDataset } from '../../common/normalise-dataset.mjs';
-import { ElementError } from '../../errors/index.mjs';
+import { ElementError, ConfigError } from '../../errors/index.mjs';
 import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs';
 
 /**
@@ -28,7 +28,11 @@ class ErrorSummary extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset($module.dataset, ErrorSummary.schema));
+    try {
+      this.config = mergeConfigs([ErrorSummary.defaults, config, normaliseDataset($module.dataset, ErrorSummary.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Error summary: ${error instanceof Error ? error.message : String(error)}`);
+    }
     if (!this.config.disableAutoFocus) {
       setFocus(this.$module);
     }
diff --git a/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.js b/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.js
index 200424d86..10c1fec07 100644
--- a/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.js
@@ -37,19 +37,23 @@
    * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
    */
 
-  function mergeConfigs(...configObjects) {
+  function mergeConfigs(configObjects, {
+    path = []
+  } = {}) {
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
       for (const key of Object.keys(configObject)) {
         const option = formattedConfigObject[key];
         const override = configObject[key];
-        const nonObjectOverridesObject = isObject(option) && !isObject(override);
-        if (!nonObjectOverridesObject) {
-          if (isObject(option) && isObject(override)) {
-            formattedConfigObject[key] = mergeConfigs(option, override);
-          } else {
-            formattedConfigObject[key] = override;
-          }
+        if (isObject(option) && !isObject(override)) {
+          throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+        }
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs([option, override], {
+            path: [...path, key]
+          });
+        } else {
+          formattedConfigObject[key] = override;
         }
       }
     }
@@ -63,9 +67,17 @@
       const keyParts = key.split('.');
       if (keyParts[0] === namespace) {
         keyParts.shift();
+        if (!keyParts.length) {
+          throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+        }
         let current = newObject;
+        const path = [];
         for (const name of keyParts) {
+          path.push(name);
           if (!isObject(current[name])) {
+            if (name in current) {
+              throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+            }
             current[name] = {};
           }
           const next = current[name];
@@ -148,6 +160,12 @@
       this.name = 'SupportError';
     }
   }
+  class ConfigError extends GOVUKFrontendError {
+    constructor(...args) {
+      super(...args);
+      this.name = 'ConfigError';
+    }
+  }
   class ElementError extends GOVUKFrontendError {
     constructor(messageOrOptions) {
       let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
@@ -411,7 +429,11 @@
           identifier: 'Button (`.govuk-exit-this-page__button`)'
         });
       }
-      this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset($module.dataset, ExitThisPage.schema));
+      try {
+        this.config = mergeConfigs([ExitThisPage.defaults, config, normaliseDataset($module.dataset, ExitThisPage.schema)]);
+      } catch (error) {
+        throw new ConfigError(`Exit this page: ${error instanceof Error ? error.message : String(error)}`);
+      }
       this.i18n = new I18n(this.config.i18n);
       this.$module = $module;
       this.$button = $button;
diff --git a/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.mjs
index 5e1b111d2..d5956610d 100644
--- a/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.mjs
@@ -31,19 +31,23 @@ function normaliseString(value, options) {
  * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
  */
 
-function mergeConfigs(...configObjects) {
+function mergeConfigs(configObjects, {
+  path = []
+} = {}) {
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
     for (const key of Object.keys(configObject)) {
       const option = formattedConfigObject[key];
       const override = configObject[key];
-      const nonObjectOverridesObject = isObject(option) && !isObject(override);
-      if (!nonObjectOverridesObject) {
-        if (isObject(option) && isObject(override)) {
-          formattedConfigObject[key] = mergeConfigs(option, override);
-        } else {
-          formattedConfigObject[key] = override;
-        }
+      if (isObject(option) && !isObject(override)) {
+        throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+      }
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs([option, override], {
+          path: [...path, key]
+        });
+      } else {
+        formattedConfigObject[key] = override;
       }
     }
   }
@@ -57,9 +61,17 @@ function extractConfigByNamespace(dataset, namespace) {
     const keyParts = key.split('.');
     if (keyParts[0] === namespace) {
       keyParts.shift();
+      if (!keyParts.length) {
+        throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+      }
       let current = newObject;
+      const path = [];
       for (const name of keyParts) {
+        path.push(name);
         if (!isObject(current[name])) {
+          if (name in current) {
+            throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+          }
           current[name] = {};
         }
         const next = current[name];
@@ -142,6 +154,12 @@ class SupportError extends GOVUKFrontendError {
     this.name = 'SupportError';
   }
 }
+class ConfigError extends GOVUKFrontendError {
+  constructor(...args) {
+    super(...args);
+    this.name = 'ConfigError';
+  }
+}
 class ElementError extends GOVUKFrontendError {
   constructor(messageOrOptions) {
     let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
@@ -405,7 +423,11 @@ class ExitThisPage extends GOVUKFrontendComponent {
         identifier: 'Button (`.govuk-exit-this-page__button`)'
       });
     }
-    this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset($module.dataset, ExitThisPage.schema));
+    try {
+      this.config = mergeConfigs([ExitThisPage.defaults, config, normaliseDataset($module.dataset, ExitThisPage.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Exit this page: ${error instanceof Error ? error.message : String(error)}`);
+    }
     this.i18n = new I18n(this.config.i18n);
     this.$module = $module;
     this.$button = $button;
diff --git a/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.mjs b/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.mjs
index 4dd128bf6..c510258c0 100644
--- a/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.mjs
@@ -1,6 +1,6 @@
 import { mergeConfigs } from '../../common/index.mjs';
 import { normaliseDataset } from '../../common/normalise-dataset.mjs';
-import { ElementError } from '../../errors/index.mjs';
+import { ElementError, ConfigError } from '../../errors/index.mjs';
 import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs';
 import { I18n } from '../../i18n.mjs';
 
@@ -45,7 +45,11 @@ class ExitThisPage extends GOVUKFrontendComponent {
         identifier: 'Button (`.govuk-exit-this-page__button`)'
       });
     }
-    this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset($module.dataset, ExitThisPage.schema));
+    try {
+      this.config = mergeConfigs([ExitThisPage.defaults, config, normaliseDataset($module.dataset, ExitThisPage.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Exit this page: ${error instanceof Error ? error.message : String(error)}`);
+    }
     this.i18n = new I18n(this.config.i18n);
     this.$module = $module;
     this.$button = $button;
diff --git a/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.js b/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.js
index 64db9e782..8f53f484b 100644
--- a/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.js
@@ -37,19 +37,23 @@
    * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
    */
 
-  function mergeConfigs(...configObjects) {
+  function mergeConfigs(configObjects, {
+    path = []
+  } = {}) {
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
       for (const key of Object.keys(configObject)) {
         const option = formattedConfigObject[key];
         const override = configObject[key];
-        const nonObjectOverridesObject = isObject(option) && !isObject(override);
-        if (!nonObjectOverridesObject) {
-          if (isObject(option) && isObject(override)) {
-            formattedConfigObject[key] = mergeConfigs(option, override);
-          } else {
-            formattedConfigObject[key] = override;
-          }
+        if (isObject(option) && !isObject(override)) {
+          throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+        }
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs([option, override], {
+            path: [...path, key]
+          });
+        } else {
+          formattedConfigObject[key] = override;
         }
       }
     }
@@ -63,9 +67,17 @@
       const keyParts = key.split('.');
       if (keyParts[0] === namespace) {
         keyParts.shift();
+        if (!keyParts.length) {
+          throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+        }
         let current = newObject;
+        const path = [];
         for (const name of keyParts) {
+          path.push(name);
           if (!isObject(current[name])) {
+            if (name in current) {
+              throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+            }
             current[name] = {};
           }
           const next = current[name];
@@ -172,6 +184,12 @@
       this.name = 'SupportError';
     }
   }
+  class ConfigError extends GOVUKFrontendError {
+    constructor(...args) {
+      super(...args);
+      this.name = 'ConfigError';
+    }
+  }
   class ElementError extends GOVUKFrontendError {
     constructor(messageOrOptions) {
       let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
@@ -223,7 +241,11 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset($module.dataset, NotificationBanner.schema));
+      try {
+        this.config = mergeConfigs([NotificationBanner.defaults, config, normaliseDataset($module.dataset, NotificationBanner.schema)]);
+      } catch (error) {
+        throw new ConfigError(`Notification banner: ${error instanceof Error ? error.message : String(error)}`);
+      }
       if (this.$module.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
         setFocus(this.$module);
       }
diff --git a/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.mjs
index aafa22795..4b5224392 100644
--- a/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.mjs
@@ -31,19 +31,23 @@ function normaliseString(value, options) {
  * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
  */
 
-function mergeConfigs(...configObjects) {
+function mergeConfigs(configObjects, {
+  path = []
+} = {}) {
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
     for (const key of Object.keys(configObject)) {
       const option = formattedConfigObject[key];
       const override = configObject[key];
-      const nonObjectOverridesObject = isObject(option) && !isObject(override);
-      if (!nonObjectOverridesObject) {
-        if (isObject(option) && isObject(override)) {
-          formattedConfigObject[key] = mergeConfigs(option, override);
-        } else {
-          formattedConfigObject[key] = override;
-        }
+      if (isObject(option) && !isObject(override)) {
+        throw new TypeError(`Trying to merge a non-object value over an object value for \`${[...path, key].join('.')}\``);
+      }
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs([option, override], {
+          path: [...path, key]
+        });
+      } else {
+        formattedConfigObject[key] = override;
       }
     }
   }
@@ -57,9 +61,17 @@ function extractConfigByNamespace(dataset, namespace) {
     const keyParts = key.split('.');
     if (keyParts[0] === namespace) {
       keyParts.shift();
+      if (!keyParts.length) {
+        throw new TypeError(`\`data-${namespace}\` cannot exist on its own`);
+      }
       let current = newObject;
+      const path = [];
       for (const name of keyParts) {
+        path.push(name);
         if (!isObject(current[name])) {
+          if (name in current) {
+            throw new TypeError(`\`data-${namespace}.${keyParts.join('.')}\` cannot exist if \`data-${namespace}.${path.join('.')}\` is present`);
+          }
           current[name] = {};
         }
         const next = current[name];
@@ -166,6 +178,12 @@ class SupportError extends GOVUKFrontendError {
     this.name = 'SupportError';
   }
 }
+class ConfigError extends GOVUKFrontendError {
+  constructor(...args) {
+    super(...args);
+    this.name = 'ConfigError';
+  }
+}
 class ElementError extends GOVUKFrontendError {
   constructor(messageOrOptions) {
     let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
@@ -217,7 +235,11 @@ class NotificationBanner extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset($module.dataset, NotificationBanner.schema));
+    try {
+      this.config = mergeConfigs([NotificationBanner.defaults, config, normaliseDataset($module.dataset, NotificationBanner.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Notification banner: ${error instanceof Error ? error.message : String(error)}`);
+    }
     if (this.$module.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
       setFocus(this.$module);
     }
diff --git a/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.mjs b/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.mjs
index 772d37bc0..eed779b96 100644
--- a/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.mjs
@@ -1,6 +1,6 @@
 import { mergeConfigs, setFocus } from '../../common/index.mjs';
 import { normaliseDataset } from '../../common/normalise-dataset.mjs';
-import { ElementError } from '../../errors/index.mjs';
+import { ElementError, ConfigError } from '../../errors/index.mjs';
 import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs';
 
 /**
@@ -25,7 +25,11 @@ class NotificationBanner extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset($module.dataset, NotificationBanner.schema));
+    try {
+      this.config = mergeConfigs([NotificationBanner.defaults, config, normaliseDataset($module.dataset, NotificationBanner.schema)]);
+    } catch (error) {
+      throw new ConfigError(`Notification banner: ${error instanceof Error ? error.message : String(error)}`);
+    }
     if (this.$module.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
       setFocus(this.$module);
     }

Action run for 6c63991

@romaricpascal
Copy link
Member Author

Closing to avoid noise in the list of PRs now that #4792 is merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants