You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/07/22 19:54:15 UTC

(superset) 01/01: fix(plugin-chart-echarts): stop clobbering echarts builtin locales with empty objects

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch fix/echarts6-locale-clobber
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 72fbf1358d6afa81eed3bdcf435fbd273ec1032c
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Wed Jul 22 12:53:08 2026 -0700

    fix(plugin-chart-echarts): stop clobbering echarts builtin locales with empty objects
    
    The locale loaders imported echarts' i18n/lang*.js bundles, which are
    side-effect UMD modules: they register the locale against a second
    echarts instance (require('echarts/lib/echarts')) and export nothing.
    Webpack's CommonJS interop therefore yielded .default === {}, and
    registering that empty object under a builtin locale name ('EN'/'ZH')
    wiped the builtin definitions. Every time-axis chart then crashed
    inside echarts' time formatter ("TypeError: Cannot read properties of
    null (reading '0')") while resolving time.month from the empty locale.
    
    Import the lang*-obj.js variants instead — they export the locale
    object itself, carry no side effects, and drop the duplicated echarts
    lib bundle from the locale chunks. Guard registerLocale against
    content-free locale objects, and register LegacyGridContainLabel so
    grid.containLabel keeps working under echarts 6 instead of being
    ignored and clipping axis labels.
    
    Co-Authored-By: Claude Fable 5 <no...@anthropic.com>
---
 .../plugin-chart-echarts/src/components/Echart.tsx |  64 +------------
 .../src/components/echartsLocale.ts                | 104 +++++++++++++++++++++
 .../test/components/echartsLocale.test.ts          |  78 ++++++++++++++++
 3 files changed, 187 insertions(+), 59 deletions(-)

diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
index 043dd716efb..4eba1d22a4a 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
@@ -63,7 +63,7 @@ import {
   MarkAreaComponent,
   MarkLineComponent,
 } from 'echarts/components';
-import { LabelLayout } from 'echarts/features';
+import { LabelLayout, LegacyGridContainLabel } from 'echarts/features';
 import {
   EchartsHandler,
   EchartsProps,
@@ -72,6 +72,7 @@ import {
 } from '../types';
 import { DEFAULT_LOCALE } from '../constants';
 import { mergeEchartsThemeOverrides } from '../utils/themeOverrides';
+import { loadLocale } from './echartsLocale';
 
 // Define this interface here to avoid creating a dependency back to superset-frontend,
 // TODO: to move the type to @superset-ui/core
@@ -119,66 +120,11 @@ use([
   TitleComponent,
   VisualMapComponent,
   LabelLayout,
+  // Superset chart options rely on `grid.containLabel`, which echarts 6
+  // ignores (clipping axis labels) unless this legacy feature is registered.
+  LegacyGridContainLabel,
 ]);
 
-// Explicit per-locale imports rather than a template-literal dynamic
-// import: a computed import makes bundlers build a "context module" over
-// echarts/i18n, and resolving that directory through the echarts package's
-// `exports` map fails intermittently in webpack incremental builds
-// ("Package path ./i18n is exported ... but no valid target file was
-// found"). A static map is also the only thing that lets bundlers
-// code-split exactly the locales listed here. Keys are Superset locales
-// uppercased (see LANGUAGES in superset/config.py); values point at the
-// echarts bundle, whose naming differs for some locales (Slovenian is
-// langSI, Brazilian Portuguese is langPT-br). Superset locales absent
-// from this map fall back to English.
-type EChartsLocaleOption = Parameters<typeof registerLocale>[1];
-
-const LOCALE_LOADERS: Record<
-  string,
-  () => Promise<{ default: EChartsLocaleOption }>
-> = {
-  AR: () => import('echarts/i18n/langAR.js'),
-  CS: () => import('echarts/i18n/langCS.js'),
-  DE: () => import('echarts/i18n/langDE.js'),
-  EL: () => import('echarts/i18n/langEL.js'),
-  EN: () => import('echarts/i18n/langEN.js'),
-  ES: () => import('echarts/i18n/langES.js'),
-  FA: () => import('echarts/i18n/langFA.js'),
-  FI: () => import('echarts/i18n/langFI.js'),
-  FR: () => import('echarts/i18n/langFR.js'),
-  HU: () => import('echarts/i18n/langHU.js'),
-  IT: () => import('echarts/i18n/langIT.js'),
-  JA: () => import('echarts/i18n/langJA.js'),
-  KO: () => import('echarts/i18n/langKO.js'),
-  LV: () => import('echarts/i18n/langLV.js'),
-  NL: () => import('echarts/i18n/langNL.js'),
-  PL: () => import('echarts/i18n/langPL.js'),
-  RO: () => import('echarts/i18n/langRO.js'),
-  PT_BR: () => import('echarts/i18n/langPT-br.js'),
-  RU: () => import('echarts/i18n/langRU.js'),
-  SL: () => import('echarts/i18n/langSI.js'),
-  SV: () => import('echarts/i18n/langSV.js'),
-  TH: () => import('echarts/i18n/langTH.js'),
-  TR: () => import('echarts/i18n/langTR.js'),
-  UK: () => import('echarts/i18n/langUK.js'),
-  VI: () => import('echarts/i18n/langVI.js'),
-  ZH: () => import('echarts/i18n/langZH.js'),
-};
-
-const loadLocale = async (locale: string) => {
-  const loader = LOCALE_LOADERS[locale];
-  if (!loader) {
-    // Locale not supported in ECharts
-    return undefined;
-  }
-  try {
-    return (await loader()).default;
-  } catch {
-    return undefined;
-  }
-};
-
 // Report/thumbnail screenshots use standalone="true" (charts) or 3 (reports);
 // live embeds use 1/2 and keep animation. See superset/utils/screenshots.py.
 export function isReportScreenshotMode(): boolean {
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/components/echartsLocale.ts b/superset-frontend/plugins/plugin-chart-echarts/src/components/echartsLocale.ts
new file mode 100644
index 00000000000..02b5d430504
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/echartsLocale.ts
@@ -0,0 +1,104 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { registerLocale } from 'echarts/core';
+
+export type EChartsLocaleOption = Parameters<typeof registerLocale>[1];
+
+// Import the `lang*-obj.js` locale bundles, NOT the plain `lang*.js` ones.
+// The plain files are side-effect UMD modules: they call
+// `registerLocale` against `require('echarts/lib/echarts')` — a second,
+// separate echarts instance from the treeshaken `echarts/core` the app
+// uses — and export nothing. Under webpack's CommonJS interop that gives
+// `.default === {}`, and registering that empty object under a builtin
+// locale name ('EN'/'ZH') wipes out the builtin definitions: every
+// time-axis chart then crashes in echarts' time formatter reading
+// `time.month` from the empty locale. The `-obj` variants export the
+// locale object itself, with no side effects and no duplicate echarts.
+//
+// Explicit per-locale imports rather than a template-literal dynamic
+// import: a computed import makes bundlers build a "context module" over
+// echarts/i18n, and resolving that directory through the echarts package's
+// `exports` map fails intermittently in webpack incremental builds
+// ("Package path ./i18n is exported ... but no valid target file was
+// found"). A static map is also the only thing that lets bundlers
+// code-split exactly the locales listed here. Keys are Superset locales
+// uppercased (see LANGUAGES in superset/config.py); values point at the
+// echarts bundle, whose naming differs for some locales (Slovenian is
+// langSI, Brazilian Portuguese is langPT-br). Superset locales absent
+// from this map fall back to English.
+export const LOCALE_LOADERS: Record<
+  string,
+  () => Promise<{ default: EChartsLocaleOption }>
+> = {
+  AR: () => import('echarts/i18n/langAR-obj.js'),
+  CS: () => import('echarts/i18n/langCS-obj.js'),
+  DE: () => import('echarts/i18n/langDE-obj.js'),
+  EL: () => import('echarts/i18n/langEL-obj.js'),
+  EN: () => import('echarts/i18n/langEN-obj.js'),
+  ES: () => import('echarts/i18n/langES-obj.js'),
+  FA: () => import('echarts/i18n/langFA-obj.js'),
+  FI: () => import('echarts/i18n/langFI-obj.js'),
+  FR: () => import('echarts/i18n/langFR-obj.js'),
+  HU: () => import('echarts/i18n/langHU-obj.js'),
+  IT: () => import('echarts/i18n/langIT-obj.js'),
+  JA: () => import('echarts/i18n/langJA-obj.js'),
+  KO: () => import('echarts/i18n/langKO-obj.js'),
+  LV: () => import('echarts/i18n/langLV-obj.js'),
+  NL: () => import('echarts/i18n/langNL-obj.js'),
+  PL: () => import('echarts/i18n/langPL-obj.js'),
+  RO: () => import('echarts/i18n/langRO-obj.js'),
+  PT_BR: () => import('echarts/i18n/langPT-br-obj.js'),
+  RU: () => import('echarts/i18n/langRU-obj.js'),
+  SL: () => import('echarts/i18n/langSI-obj.js'),
+  SV: () => import('echarts/i18n/langSV-obj.js'),
+  TH: () => import('echarts/i18n/langTH-obj.js'),
+  TR: () => import('echarts/i18n/langTR-obj.js'),
+  UK: () => import('echarts/i18n/langUK-obj.js'),
+  VI: () => import('echarts/i18n/langVI-obj.js'),
+  ZH: () => import('echarts/i18n/langZH-obj.js'),
+};
+
+/**
+ * Resolve the ECharts locale object for a Superset locale, or undefined
+ * when the locale is unsupported or the bundle yields no usable content.
+ * Returning undefined makes the caller skip `registerLocale`, so echarts
+ * keeps its builtin locale definitions instead of having them overwritten
+ * by an empty object.
+ */
+export const loadLocale = async (
+  locale: string,
+): Promise<EChartsLocaleOption | undefined> => {
+  const loader = LOCALE_LOADERS[locale];
+  if (!loader) {
+    // Locale not supported in ECharts
+    return undefined;
+  }
+  try {
+    const mod = await loader();
+    // Depending on the module system, the locale object is either the
+    // module's default export or the module namespace itself.
+    const localeObj = mod.default ?? (mod as unknown as EChartsLocaleOption);
+    if (localeObj && Object.keys(localeObj).length > 0) {
+      return localeObj;
+    }
+  } catch {
+    // fall through to the builtin locale
+  }
+  return undefined;
+};
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/components/echartsLocale.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/components/echartsLocale.test.ts
new file mode 100644
index 00000000000..eba54111733
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/components/echartsLocale.test.ts
@@ -0,0 +1,78 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { init, use, registerLocale } from 'echarts/core';
+import { LineChart } from 'echarts/charts';
+import { SVGRenderer } from 'echarts/renderers';
+import { GridComponent } from 'echarts/components';
+import { LOCALE_LOADERS, loadLocale } from '../../src/components/echartsLocale';
+
+type LocaleWithTime = {
+  time?: { month?: string[]; dayOfWeek?: string[] };
+};
+
+test('loadLocale returns undefined for locales echarts does not ship', async () => {
+  expect(await loadLocale('XX')).toBeUndefined();
+});
+
+test.each(Object.keys(LOCALE_LOADERS))(
+  'locale %s loads a locale object with time names',
+  async localeKey => {
+    const localeObj = (await loadLocale(localeKey)) as LocaleWithTime;
+    // A locale object without content would clobber echarts' builtin
+    // locales when registered; missing `time` names crash every
+    // time-axis chart inside echarts' time formatter (see #42314
+    // discussion — "Cannot read properties of null (reading '0')").
+    expect(localeObj).toBeDefined();
+    expect(localeObj.time?.month).toHaveLength(12);
+    expect(localeObj.time?.dayOfWeek).toHaveLength(7);
+  },
+);
+
+test('registering the loaded EN locale keeps time-axis charts rendering', async () => {
+  use([SVGRenderer, LineChart, GridComponent]);
+  const localeObj = await loadLocale('EN');
+  expect(localeObj).toBeDefined();
+  registerLocale('EN', localeObj!);
+
+  const chart = init(null, null, {
+    renderer: 'svg',
+    ssr: true,
+    width: 400,
+    height: 300,
+    locale: 'EN',
+  });
+  chart.setOption(
+    {
+      xAxis: { type: 'time' },
+      yAxis: { type: 'value' },
+      series: [
+        {
+          type: 'line',
+          data: [
+            [new Date(1965, 0, 1).getTime(), 100],
+            [new Date(1985, 0, 1).getTime(), 200],
+            [new Date(2005, 0, 1).getTime(), 150],
+          ],
+        },
+      ],
+    },
+    { notMerge: true, lazyUpdate: false },
+  );
+  expect(chart.renderToSVGString()).toContain('<svg');
+});