From 4d56a2679e7da6395f4d7f53da5a482dc6194b45 Mon Sep 17 00:00:00 2001 From: Dan Webb Date: Sat, 21 Mar 2026 00:47:00 +0000 Subject: [PATCH] fix: guard against duplicate custom element registration Adds safeCustomElement decorator that checks customElements.get() before defining, preventing NotSupportedError when the same element is imported multiple times or when multiple versions of Evergreen are loaded on the same page. A console.warn is emitted when a duplicate registration is detected, making version conflicts visible rather than silent. Co-Authored-By: Claude Sonnet 4.6 --- src/components/canvas/Section/SectionImg.ts | 4 ++-- .../canvas/Supergraphic/Supergraphic.ts | 4 ++-- src/components/composition/App/App.ts | 4 ++-- src/components/composition/Collapse/Collapse.ts | 5 +++-- src/components/composition/Enter/Enter.ts | 4 ++-- src/components/composition/Sticky/Sticky.ts | 4 ++-- src/components/content/Icon/Icon.ts | 4 ++-- src/components/content/Img/Img.ts | 4 ++-- .../content/LoadingButton/LoadingButton.ts | 4 ++-- src/components/content/LoadingImg/LoadingImg.ts | 4 ++-- src/components/content/LoadingText/LoadingText.ts | 4 ++-- .../control/RadioCheckbox/RadioCheckbox.ts | 4 ++-- src/lib/safe-custom-element.ts | 15 +++++++++++++++ 13 files changed, 40 insertions(+), 24 deletions(-) create mode 100644 src/lib/safe-custom-element.ts diff --git a/src/components/canvas/Section/SectionImg.ts b/src/components/canvas/Section/SectionImg.ts index 095a8a79..2e4da39b 100644 --- a/src/components/canvas/Section/SectionImg.ts +++ b/src/components/canvas/Section/SectionImg.ts @@ -1,14 +1,14 @@ import { LitElement, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; import { JSXCustomElement } from '../../../types/jsx-custom-element.type'; +import { safeCustomElement } from '@/lib/safe-custom-element'; export interface SectionImgAttributes { text?: 'light' | 'dark'; layout?: 'nested' | 'default'; } -@customElement('evg-section-img') +@safeCustomElement('evg-section-img') export class SectionImg extends LitElement { static readonly properties = { treatment: { type: String, reflect: true, default: 'dark' }, diff --git a/src/components/canvas/Supergraphic/Supergraphic.ts b/src/components/canvas/Supergraphic/Supergraphic.ts index 61282856..f14617ec 100644 --- a/src/components/canvas/Supergraphic/Supergraphic.ts +++ b/src/components/canvas/Supergraphic/Supergraphic.ts @@ -1,8 +1,8 @@ import { LitElement, css, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { JSXCustomElement } from '../../../types/jsx-custom-element.type'; +import { safeCustomElement } from '@/lib/safe-custom-element'; import globe from './globe.svg?raw'; @@ -10,7 +10,7 @@ export interface SupergraphicAttributes { position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; } -@customElement('evg-supergraphic') +@safeCustomElement('evg-supergraphic') export class Supergraphic extends LitElement { position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; diff --git a/src/components/composition/App/App.ts b/src/components/composition/App/App.ts index 9cc9f449..bcab8d69 100644 --- a/src/components/composition/App/App.ts +++ b/src/components/composition/App/App.ts @@ -1,13 +1,13 @@ import { LitElement, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; import { JSXCustomElement } from '../../../types/jsx-custom-element.type'; +import { safeCustomElement } from '@/lib/safe-custom-element'; export interface AppAttributes { header?: 'sticky'; } -@customElement('evg-app') +@safeCustomElement('evg-app') export class App extends LitElement { static readonly properties = { header: { type: String, reflect: true }, diff --git a/src/components/composition/Collapse/Collapse.ts b/src/components/composition/Collapse/Collapse.ts index 339b63a2..a8f9ea10 100644 --- a/src/components/composition/Collapse/Collapse.ts +++ b/src/components/composition/Collapse/Collapse.ts @@ -1,13 +1,14 @@ import { LitElement, css, html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { property } from 'lit/decorators.js'; +import { safeCustomElement } from '@/lib/safe-custom-element'; import { JSXCustomElement } from '@/types/jsx-custom-element.type'; export interface CollapseAttributes { open?: boolean; } -@customElement('evg-collapse') +@safeCustomElement('evg-collapse') export class Collapse extends LitElement { @property({ reflect: true, type: Boolean }) open?: boolean; diff --git a/src/components/composition/Enter/Enter.ts b/src/components/composition/Enter/Enter.ts index 7e8c7fee..56aa05a5 100644 --- a/src/components/composition/Enter/Enter.ts +++ b/src/components/composition/Enter/Enter.ts @@ -1,7 +1,7 @@ import { LitElement, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; import { JSXCustomElement } from '../../../types/jsx-custom-element.type'; +import { safeCustomElement } from '@/lib/safe-custom-element'; import { HtmlBoolean } from 'src/types/html-boolean.type'; @@ -15,7 +15,7 @@ export interface EnterAttributes { fill?: HtmlBoolean; } -@customElement('evg-enter') +@safeCustomElement('evg-enter') export class Enter extends LitElement { private observer?: IntersectionObserver; static readonly properties = { diff --git a/src/components/composition/Sticky/Sticky.ts b/src/components/composition/Sticky/Sticky.ts index 1da16b77..a2848b43 100644 --- a/src/components/composition/Sticky/Sticky.ts +++ b/src/components/composition/Sticky/Sticky.ts @@ -1,6 +1,6 @@ import { html, LitElement } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { safeCustomElement } from '@/lib/safe-custom-element'; import { HtmlBoolean } from '@/types/html-boolean.type'; import { JSXCustomElement } from '@/types/jsx-custom-element.type'; @@ -9,7 +9,7 @@ export interface StickyAttributes { top?: string; } -@customElement('evg-sticky') +@safeCustomElement('evg-sticky') export class Sticky extends LitElement { static readonly properties = { top: { diff --git a/src/components/content/Icon/Icon.ts b/src/components/content/Icon/Icon.ts index 5dd3cc04..ac87d578 100644 --- a/src/components/content/Icon/Icon.ts +++ b/src/components/content/Icon/Icon.ts @@ -1,8 +1,8 @@ import { LitElement, html, nothing, css } from 'lit'; -import { customElement } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { JSXCustomElement } from '../../../types/jsx-custom-element.type'; +import { safeCustomElement } from '@/lib/safe-custom-element'; import { icons, @@ -18,7 +18,7 @@ export interface IconAttributes { set?: 'default' | 'functional' | 'distinctive'; } -@customElement('evg-icon') +@safeCustomElement('evg-icon') export class Icon extends LitElement { variant?: 'default' | 'circle'; label?: string; diff --git a/src/components/content/Img/Img.ts b/src/components/content/Img/Img.ts index 74a2b6e2..f4b63387 100644 --- a/src/components/content/Img/Img.ts +++ b/src/components/content/Img/Img.ts @@ -1,9 +1,9 @@ import { LitElement, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; import { HtmlBoolean } from '../../../types/html-boolean.type'; import { JSXCustomElement } from '../../../types/jsx-custom-element.type'; import { Radius } from '../../../types/tokens.type'; +import { safeCustomElement } from '@/lib/safe-custom-element'; export interface ImgAttributes { block?: HtmlBoolean; @@ -14,7 +14,7 @@ export interface ImgAttributes { objectPosition?: string; } -@customElement('evg-img') +@safeCustomElement('evg-img') export class Img extends LitElement { static readonly properties = { ariaHidden: { diff --git a/src/components/content/LoadingButton/LoadingButton.ts b/src/components/content/LoadingButton/LoadingButton.ts index 891cc792..76bd8b39 100644 --- a/src/components/content/LoadingButton/LoadingButton.ts +++ b/src/components/content/LoadingButton/LoadingButton.ts @@ -1,7 +1,7 @@ import { LitElement, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; import { pulse } from '@/lib/pulse'; +import { safeCustomElement } from '@/lib/safe-custom-element'; import { HtmlBoolean } from '@/types/html-boolean.type'; import { JSXCustomElement } from '@/types/jsx-custom-element.type'; @@ -9,7 +9,7 @@ export interface LoadingButtonAttributes { ariaHidden?: HtmlBoolean; } -@customElement('evg-loading-button') +@safeCustomElement('evg-loading-button') export class LoadingButton extends LitElement { static readonly properties = { ariaHidden: { diff --git a/src/components/content/LoadingImg/LoadingImg.ts b/src/components/content/LoadingImg/LoadingImg.ts index aabef005..11319bc0 100644 --- a/src/components/content/LoadingImg/LoadingImg.ts +++ b/src/components/content/LoadingImg/LoadingImg.ts @@ -1,7 +1,7 @@ import { LitElement, css, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; import { pulse } from '@/lib/pulse'; +import { safeCustomElement } from '@/lib/safe-custom-element'; import { JSXCustomElement } from '@/types/jsx-custom-element.type'; export interface LoadingImgAttributes { @@ -9,7 +9,7 @@ export interface LoadingImgAttributes { height?: string; } -@customElement('evg-loading-img') +@safeCustomElement('evg-loading-img') export class LoadingImg extends LitElement { static readonly properties = { width: { diff --git a/src/components/content/LoadingText/LoadingText.ts b/src/components/content/LoadingText/LoadingText.ts index 443fd94f..bb3ebe6c 100644 --- a/src/components/content/LoadingText/LoadingText.ts +++ b/src/components/content/LoadingText/LoadingText.ts @@ -1,7 +1,7 @@ import { LitElement, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; import { pulse } from '@/lib/pulse'; +import { safeCustomElement } from '@/lib/safe-custom-element'; import { HtmlBoolean } from '@/types/html-boolean.type'; import { JSXCustomElement } from '@/types/jsx-custom-element.type'; @@ -9,7 +9,7 @@ export interface LoadingTextAttributes { ariaHidden?: HtmlBoolean; } -@customElement('evg-loading-text') +@safeCustomElement('evg-loading-text') export class LoadingText extends LitElement { static readonly properties = { ariaHidden: { diff --git a/src/components/control/RadioCheckbox/RadioCheckbox.ts b/src/components/control/RadioCheckbox/RadioCheckbox.ts index 122b8ff1..51a9eaf4 100644 --- a/src/components/control/RadioCheckbox/RadioCheckbox.ts +++ b/src/components/control/RadioCheckbox/RadioCheckbox.ts @@ -1,13 +1,13 @@ import { LitElement, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; import { JSXCustomElement } from '../../../types/jsx-custom-element.type'; +import { safeCustomElement } from '@/lib/safe-custom-element'; export interface RadioCheckboxAttributes { state?: 'valid' | 'invalid'; } -@customElement('evg-radio-checkbox') +@safeCustomElement('evg-radio-checkbox') export class RadioCheckbox extends LitElement { static readonly properties = { state: { type: String, reflect: true }, diff --git a/src/lib/safe-custom-element.ts b/src/lib/safe-custom-element.ts new file mode 100644 index 00000000..9b389ea3 --- /dev/null +++ b/src/lib/safe-custom-element.ts @@ -0,0 +1,15 @@ +import { customElement } from 'lit/decorators.js'; + +export function safeCustomElement(tagName: string) { + return (classOrTarget: CustomElementConstructor, context?: unknown): void => { + if (customElements.get(tagName)) { + console.warn( + `[Evergreen] "${tagName}" is already registered. ` + + `You may have multiple versions of @wrap.ngo/evergreen loaded, ` + + `which can cause unexpected behaviour.`, + ); + return; + } + customElement(tagName)(classOrTarget as any, context as any); + }; +}