Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions superset-frontend/src/preamble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
* under the License.
*/

import type { LanguagePack } from '@apache-superset/core/translation';

const mockConfigure = jest.fn();
const mockInitFeatureFlags = jest.fn();
const mockMakeApi = jest.fn(() => jest.fn());
Expand Down Expand Up @@ -111,3 +113,83 @@ test('falls back to en when passing locale to setupFormatters', async () => {

expect(mockSetupFormatters).toHaveBeenCalledWith({}, {}, 'en');
});

// --- language pack loading semantics (issues #35330, PR #41780) -----------
// English: nothing is loaded at all. Non-English: the pack is expected to
// already be on window (set by the versioned classic <script> tag spa.html
// emits before the entry bundle), so configuration is synchronous and no
// English flash can occur. The async fetch is a fallback only, and it only
// ever requests the selected locale.

const FAKE_PACK: LanguagePack = {
domain: 'superset',
locale_data: {
superset: {
'': {
domain: 'superset',
lang: 'fr',
plural_forms: 'nplurals=2; plural=(n > 1);',
},
},
},
};

afterEach(() => {
delete window.__SUPERSET_LANGUAGE_PACK__;
});

test('configures synchronously from the window pack without fetching', async () => {
mockGetBootstrapData.mockReturnValue(bootstrapData('fr'));
window.__SUPERSET_LANGUAGE_PACK__ = FAKE_PACK;
const fetchSpy = jest.spyOn(global, 'fetch');

await runPreamble();

expect(mockConfigure).toHaveBeenCalledWith({ languagePack: FAKE_PACK });
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockDayjsLocale).toHaveBeenCalledWith('fr');
fetchSpy.mockRestore();
});

test('prefers an operator-supplied bootstrap pack over the window pack', async () => {
const overridePack = { ...FAKE_PACK, domain: 'override' };
const data = bootstrapData('fr');
(data.common as Record<string, unknown>).language_pack = overridePack;
mockGetBootstrapData.mockReturnValue(data);
window.__SUPERSET_LANGUAGE_PACK__ = FAKE_PACK;

await runPreamble();

expect(mockConfigure).toHaveBeenCalledWith({ languagePack: overridePack });
});

test('loads no language pack at all for English', async () => {
mockGetBootstrapData.mockReturnValue(bootstrapData('en'));
const fetchSpy = jest.spyOn(global, 'fetch');

await runPreamble();

expect(fetchSpy).not.toHaveBeenCalled();
// The translator is configured bare (identity translations), never with
// a pack: English pays zero payload.
expect(mockConfigure).toHaveBeenCalledWith();
fetchSpy.mockRestore();
});

test('fallback fetch requests only the selected locale and blocks until configured', async () => {
mockGetBootstrapData.mockReturnValue(bootstrapData('fr'));
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve(FAKE_PACK),
} as unknown as Response);

await runPreamble();

expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(mockMakeUrl).toHaveBeenCalledWith('/language_pack/fr/');
// configure() resolves before initPreamble() does, so nothing renders
// untranslated even on the fallback path.
expect(mockConfigure).toHaveBeenCalledWith({ languagePack: FAKE_PACK });
expect(window.__SUPERSET_LANGUAGE_PACK__).toEqual(FAKE_PACK);
fetchSpy.mockRestore();
});
14 changes: 8 additions & 6 deletions superset-frontend/src/preamble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,14 @@ export default function initPreamble(): Promise<void> {
setupClient({ appRoot: applicationRoot() });

// Load language pack before rendering.
// Prefer the bootstrap-injected pack (stashed on window by the inline
// script in spa.html, sourced from common.language_pack) so
// module-level `const X = t('...')` calls in code-split chunks all
// see a configured translator. Fall back to the async fetch only
// when the bootstrap payload didn't carry the pack (e.g. embedded
// or a legacy entry that doesn't extend spa.html). See issue #35330.
// Prefer the pack already on hand: either an operator-supplied
// common.language_pack (COMMON_BOOTSTRAP_OVERRIDES_FUNC) or the
// window global set by the versioned script tag spa.html loads
// before this bundle. Either way, module-level `const X = t('...')`
// calls in code-split chunks all see a configured translator. Fall
// back to the async fetch only when neither is present (e.g. the
// script tag failed to load, or a legacy entry that doesn't extend
// spa.html). See issue #35330.
if (lang !== 'en') {
const bootstrapPack =
(bootstrapData.common as { language_pack?: LanguagePack })
Expand Down
7 changes: 6 additions & 1 deletion superset/embedded/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@
from superset.daos.dashboard import EmbeddedDashboardDAO
from superset.superset_typing import FlaskResponse
from superset.utils import json
from superset.views.base import BaseSupersetView, common_bootstrap_payload
from superset.views.base import (
BaseSupersetView,
common_bootstrap_payload,
get_language_pack_template_context,
)


class EmbeddedView(BaseSupersetView):
Expand Down Expand Up @@ -110,4 +114,5 @@ def embedded(
bootstrap_data=json.dumps(
bootstrap_data, default=json.pessimistic_json_iso_dttm_ser
),
**get_language_pack_template_context(bootstrap_data["common"]),
)
60 changes: 33 additions & 27 deletions superset/templates/superset/spa.html
Original file line number Diff line number Diff line change
Expand Up @@ -150,35 +150,41 @@

{% block tail_js %}
{#
Expose the language pack on window BEFORE the entry bundle loads,
so module-level `const X = t('...')` calls across webpack-split
chunks all find a configured translator on first access. The
bootstrap div is the single source of truth; we parse it once and
stash the pack on a global that the translator reads lazily.
See upstream issue #35330.

Trade-off: this ships the full Jed pack inline in every full-page
HTML response (only for non-English locales; English injects null)
rather than via the separately-cacheable `/language_pack/` fetch.
That synchronous availability is the whole point — the async fetch
can't guarantee the pack is configured before a code-split chunk
evaluates. Acceptable for an SPA where hard reloads are rare.
Load the language pack BEFORE the entry bundle so module-level
`const X = t('...')` calls across webpack-split chunks all find a
configured translator on first access (upstream issue #35330). The
script sets window.__SUPERSET_LANGUAGE_PACK__ and is content-addressed
(/language_pack/<lang>/<version>/script.js), so browsers cache it as
immutable across page loads and cache-bust automatically whenever
translations change. English emits no tag at all. If the script fails
to load, preamble.ts falls back to the async language pack fetch.
#}
<script nonce="{{ macros.get_nonce() }}">
(function () {
try {
var el = document.getElementById('app');
if (!el) return;
var data = JSON.parse(el.getAttribute('data-bootstrap') || '{}');
var pack = data && data.common && data.common.language_pack;
if (pack) {
window.__SUPERSET_LANGUAGE_PACK__ = pack;
{% if language_pack_src %}
<script src="{{ language_pack_src }}" nonce="{{ macros.get_nonce() }}"></script>
{% endif %}
{#
Operators can still supply a pack directly in the bootstrap payload
via COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical #35330
workaround). When one is present, stash it on window here instead of
emitting the versioned script tag, so early chunks see the override.
#}
{% if language_pack_inline %}
<script nonce="{{ macros.get_nonce() }}">
(function () {
try {
var el = document.getElementById('app');
if (!el) return;
var data = JSON.parse(el.getAttribute('data-bootstrap') || '{}');
var pack = data && data.common && data.common.language_pack;
if (pack) {
window.__SUPERSET_LANGUAGE_PACK__ = pack;
}
} catch (e) {
/* swallow: fall back to async fetch in preamble.ts */
}
} catch (e) {
/* swallow: fall back to async fetch in preamble.ts */
}
})();
</script>
})();
</script>
{% endif %}
{% if entry %}
{{ js_bundle(assets_prefix, entry) }}
{% endif %}
Expand Down
39 changes: 34 additions & 5 deletions superset/translations/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import hashlib
import json
import logging
import os
Expand All @@ -24,9 +25,41 @@
# Global caching for JSON language packs
ALL_LANGUAGE_PACKS: dict[str, dict[str, Any]] = {"en": {}}

# Global caching for language pack content hashes, used to build
# content-addressed (cache-busting) asset URLs
ALL_LANGUAGE_PACK_VERSIONS: dict[str, str] = {}

DIR = os.path.dirname(os.path.abspath(__file__))


def get_language_pack_filename(locale: str) -> str:
"""Resolve the on-disk JSON pack for a locale (empty pack for English)"""
if not locale or locale == "en":
# Forcing a dummy, quasi-empty language pack for English since the file
# in the en directory contains data with empty mappings
return DIR + "/empty_language_pack.json"
return DIR + f"/{locale}/LC_MESSAGES/messages.json"


def get_language_pack_version(locale: str) -> Optional[str]:
"""Get/cache a short content hash of the language pack file

The hash is embedded in the pack's asset URL so browsers can cache it
as immutable and pick up a fresh copy whenever translations change.
Returns None when the pack file cannot be read.
"""
version = ALL_LANGUAGE_PACK_VERSIONS.get(locale)
Comment thread
rusackas marked this conversation as resolved.
if not version:
try:
with open(get_language_pack_filename(locale), "rb") as f:
version = hashlib.sha256(f.read()).hexdigest()[:12]
ALL_LANGUAGE_PACK_VERSIONS[locale] = version
except OSError:
logger.warning("No language pack file to version for locale %s", locale)
return None
return version


def get_language_pack(locale: str) -> Optional[dict[str, Any]]:
"""Get/cache a language pack

Expand All @@ -37,11 +70,7 @@ def get_language_pack(locale: str) -> Optional[dict[str, Any]]:
"""
pack = ALL_LANGUAGE_PACKS.get(locale)
if not pack:
filename = DIR + f"/{locale}/LC_MESSAGES/messages.json"
if not locale or locale == "en":
# Forcing a dummy, quasy-empty language pack for English since the file
# in the en directory is contains data with empty mappings
filename = DIR + "/empty_language_pack.json"
filename = get_language_pack_filename(locale)
Comment thread
rusackas marked this conversation as resolved.
try:
with open(filename, encoding="utf8") as f:
pack = json.load(f)
Expand Down
60 changes: 43 additions & 17 deletions superset/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
from superset.themes.utils import (
is_valid_theme,
)
from superset.translations.utils import get_language_pack
from superset.translations.utils import get_language_pack_version
from superset.utils import core as utils, json
from superset.utils.filters import get_dataset_access_filters
from superset.utils.version import get_version_metadata, visible_version_metadata
Expand Down Expand Up @@ -578,25 +578,50 @@ def common_bootstrap_payload() -> dict[str, Any]:
# Convert locale to string for proper cache key hashing
locale_str = str(locale) if locale else None
payload = dict(cached_common_bootstrap_data(utils.get_user_id(), locale_str))
# Inject the Jed language pack outside the per-user memoize so the cached
# payload stays small and the pack is shared across users for the same
# locale. The frontend uses it to configure the translator synchronously,
# before any code-split chunk evaluates a module-level `const X = t('...')`
# (upstream issue #35330).
language = payload.get("locale")
if language and language != "en":
# Respect a pack already provided via COMMON_BOOTSTRAP_OVERRIDES_FUNC
# (the workaround in #35330 does exactly that), otherwise load the
# shared one. `get_language_pack` returns the empty English pack on a
# miss, which is the right result (English) when no translation file
# exists.
pack = payload.get("language_pack") or get_language_pack(language)
else:
pack = None
payload["language_pack"] = pack
# The language pack itself is NOT embedded in the payload: spa.html loads
# it through the content-addressed /language_pack/<lang>/<version>/script.js
# tag before the entry bundle, keeping HTML small while still configuring
# the translator synchronously (upstream issue #35330). A pack provided via
# COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical workaround) is respected
# and takes precedence over the script tag.
payload.setdefault("language_pack", None)
return payload


def get_language_pack_template_context(common: dict[str, Any]) -> dict[str, Any]:
"""Template vars controlling how spa.html delivers the language pack.

``common`` is the already-built common bootstrap payload for the request
(passed in rather than re-derived, so callers that assembled or mocked
their own payload stay consistent with what the template sees).

Three mutually exclusive outcomes:
- English (or no locale): no script tag, nothing to stash.
- Operator supplied a pack via COMMON_BOOTSTRAP_OVERRIDES_FUNC: no script
tag; spa.html stashes the bootstrap pack on window instead.
- Otherwise: emit the content-addressed script URL so the browser can
cache the pack as immutable and cache-bust on translation changes.
"""
language = common.get("locale")
if not language or language == "en":
return {"language_pack_src": None, "language_pack_inline": False}
if common.get("language_pack"):
return {"language_pack_src": None, "language_pack_inline": True}
Comment thread
rusackas marked this conversation as resolved.
version = get_language_pack_version(language)
return {
"language_pack_src": (
url_for(
"Superset.language_pack_script",
lang=language,
version=version,
)
if version
else None
),
"language_pack_inline": False,
}


def get_spa_payload(extra_data: dict[str, Any] | None = None) -> dict[str, Any]:
"""Generate standardized payload for spa.html template rendering.

Expand Down Expand Up @@ -695,6 +720,7 @@ def get_spa_template_context(
"dark_theme_bg": dark_theme_bg,
"spinner_svg": spinner_svg,
"default_title": default_title,
**get_language_pack_template_context(payload.get("common") or {}),
**template_kwargs,
}

Expand Down
Loading
Loading