mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
perf(i18n): словари settings/support/topology — ru/de/fr ленивыми чанками; потолок онбординга (#627)
- ru/de/fr трёх словарей пространств — девять ленивых чанков (по одному на пару «пространство × язык»), английский статически как слой отката; рантайм — тот же LanguageRuntime (две попытки, отпечаток сборки, fallback, общий тост отказа), src/i18n/namespace-language.ts. - Загрузчики рантаймов онбординга и редактора отдают поверхность в рендер только после того, как словарь текущего языка осел; гейт хоста учитывает словари загруженной на нём поверхности (смена языка на лету — ветка warm). Оверлей Zigbee ждёт topology до первой отрисовки. - bundle-manifest: граф lazyNamespaceLocaleFiles, девять retry-токенов со строгим счётом «ровно один». bundle-budget: потолок онбординга 28 500 (замер 27 470), редактор 245 400 → 228 900 (замер 227 869), проверки ленивости и владения по содержимому. - Смок smoke_lazy_admin_locale (AC4–AC7); регресс-смоки, переключавшие язык синхронно, ждут гейт вместо ослабления проверок; пять мутантов. - monolith-baseline bundleBytes 2 500 387 → 2 508 676: +10 чанков (rollup intro и export на каждый) и записи манифеста; gzip каждой поверхности при этом уменьшился. Issue: #627 User-Visible: no
This commit is contained in:
+149
-15
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { assertBundleManifest } from './bundle-tree.mjs';
|
||||
import { NAMESPACE_LOCALE_CHUNKS } from './bundle-manifest.mjs';
|
||||
|
||||
// #352: the budget guards the CLASS of regression — tens of kilobytes from
|
||||
// an accidentally imported dependency or an eagerly bundled dictionary —
|
||||
@@ -349,18 +350,32 @@ export const INITIAL_VIEW_CEILING_BAND = 2_000;
|
||||
*/
|
||||
export const LOW_HEADROOM_ACKNOWLEDGED_CEILING = null;
|
||||
|
||||
/**
|
||||
* Маркеры формы поддержки и граф, которому каждый принадлежит.
|
||||
*
|
||||
* #627: английский — слой отката словаря `support`, он статически в графе
|
||||
* редактора. Русский с этой задачи — отдельный ленивый чанк `support`-ru: в
|
||||
* статическом графе редактора его быть НЕ должно, иначе ×4 словарь вернулся.
|
||||
*/
|
||||
export const SUPPORT_LAZY_MARKERS = [
|
||||
'Contact details (email/tg/WhatsApp), optional.',
|
||||
'Контакт для связи (email/tg/WhatsApp), необязательно.',
|
||||
{ text: 'Contact details (email/tg/WhatsApp), optional.', graph: 'lazyEditorFiles' },
|
||||
{ text: 'Контакт для связи (email/tg/WhatsApp), необязательно.', graph: 'lazyNamespaceLocaleFiles' },
|
||||
];
|
||||
|
||||
const GRAPH_LABELS = {
|
||||
lazyEditorFiles: 'lazy editor graph',
|
||||
lazyOnboardingFiles: 'lazy onboarding graph',
|
||||
lazyNamespaceLocaleFiles: 'lazy namespace locale graph',
|
||||
};
|
||||
|
||||
/**
|
||||
* Форма поддержки живёт только в ленивом графе редактора (#423).
|
||||
* Форма поддержки живёт только в ленивых графах (#423, #627).
|
||||
*
|
||||
* Функция судит ВЛАДЕНИЕ, а не размер: маркеры формы обязаны отсутствовать в
|
||||
* `initialViewFiles` и присутствовать в `lazyEditorFiles`. Размер охраняют
|
||||
* `assertBundleBudget` и `lowHeadroomWarning` — им для этого не нужен чужой
|
||||
* номер issue (#429).
|
||||
* Функция судит ВЛАДЕНИЕ, а не размер: маркер обязан отсутствовать в
|
||||
* `initialViewFiles` и присутствовать в своём графе; маркер ленивого словаря
|
||||
* вдобавок обязан отсутствовать в статических графах редактора и онбординга.
|
||||
* Размер охраняют `assertBundleBudget` и `lowHeadroomWarning` — им для этого не
|
||||
* нужен чужой номер issue (#429).
|
||||
*/
|
||||
export function assertSupportBundleOwnership(
|
||||
manifest,
|
||||
@@ -371,13 +386,85 @@ export function assertSupportBundleOwnership(
|
||||
.map((path) => readFileSync(resolve(root, path), 'utf8'))
|
||||
.join('\n');
|
||||
const initial = graphText(manifest.initialViewFiles || []);
|
||||
const editor = graphText(manifest.lazyEditorFiles || []);
|
||||
for (const marker of markers) {
|
||||
if (initial.includes(marker)) {
|
||||
throw new Error(`support form copy leaked into initial View graph: ${marker}`);
|
||||
for (const { text, graph } of markers) {
|
||||
if (initial.includes(text)) {
|
||||
throw new Error(`support form copy leaked into initial View graph: ${text}`);
|
||||
}
|
||||
if (!editor.includes(marker)) {
|
||||
throw new Error(`support form copy missing from lazy editor graph: ${marker}`);
|
||||
if (!graphText(manifest[graph] || []).includes(text)) {
|
||||
throw new Error(`support form copy missing from ${GRAPH_LABELS[graph] || graph}: ${text}`);
|
||||
}
|
||||
if (graph === 'lazyNamespaceLocaleFiles') {
|
||||
for (const staticGraph of ['lazyEditorFiles', 'lazyOnboardingFiles']) {
|
||||
if (graphText(manifest[staticGraph] || []).includes(text)) {
|
||||
throw new Error(`support form copy of a lazy locale leaked into ${GRAPH_LABELS[staticGraph]}: ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** #627: кто статически несёт английский слой каждого пространства. */
|
||||
export const NAMESPACE_ENGLISH_CONSUMERS = {
|
||||
settings: ['lazyEditorFiles', 'lazyOnboardingFiles'],
|
||||
support: ['lazyEditorFiles'],
|
||||
topology: ['lazyEditorFiles'],
|
||||
};
|
||||
|
||||
/**
|
||||
* #627 AC3: владение словарей пространств по СОДЕРЖИМОМУ собранных файлов.
|
||||
*
|
||||
* Маркер — самая длинная строка словаря, которой нет ни в одном словаре
|
||||
* основного каталога (иначе совпадение с первым кадром было бы ложным).
|
||||
* Английский обязан лежать в статическом графе каждого потребителя и не лежать
|
||||
* в initial View; ru/de/fr — только в своём ленивом чанке: ни в initial View,
|
||||
* ни в статических графах редактора и онбординга.
|
||||
*/
|
||||
export function namespaceLocaleMarkers(source = fileURLToPath(new URL('../src/i18n/', import.meta.url))) {
|
||||
const read = (path) => JSON.parse(readFileSync(resolve(source, path), 'utf8'));
|
||||
const main = ['en', 'ru', 'de', 'fr'].map((code) => JSON.stringify(read(`${code}.json`)));
|
||||
const printable = (value) => typeof value === 'string' && value.length >= 12
|
||||
&& !/["'`\\\n$]/.test(value) && !main.some((dictionary) => dictionary.includes(value));
|
||||
const markers = [];
|
||||
for (const namespace of Object.keys(NAMESPACE_ENGLISH_CONSUMERS)) {
|
||||
for (const language of ['en', 'ru', 'de', 'fr']) {
|
||||
const values = Object.values(read(`${namespace}/${language}.json`)).filter(printable)
|
||||
.sort((left, right) => right.length - left.length || left.localeCompare(right));
|
||||
if (!values.length) throw new Error(`${namespace}/${language}: no unique marker string`);
|
||||
markers.push({ namespace, language, text: values[0] });
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
export function assertNamespaceLocaleOwnership(
|
||||
manifest,
|
||||
root = 'dist',
|
||||
markers = namespaceLocaleMarkers(),
|
||||
) {
|
||||
const graphText = (paths) => paths
|
||||
.map((path) => readFileSync(resolve(root, path), 'utf8'))
|
||||
.join('\n');
|
||||
const text = Object.fromEntries(['initialViewFiles', ...Object.keys(GRAPH_LABELS)]
|
||||
.map((graph) => [graph, graphText(manifest[graph] || [])]));
|
||||
for (const marker of markers) {
|
||||
const where = `${marker.namespace}/${marker.language}`;
|
||||
if (text.initialViewFiles.includes(marker.text)) {
|
||||
throw new Error(`${where} dictionary leaked into initial View graph: ${marker.text}`);
|
||||
}
|
||||
const owners = marker.language === 'en'
|
||||
? NAMESPACE_ENGLISH_CONSUMERS[marker.namespace]
|
||||
: ['lazyNamespaceLocaleFiles'];
|
||||
for (const graph of owners) {
|
||||
if (!text[graph].includes(marker.text)) {
|
||||
throw new Error(`${where} dictionary missing from ${GRAPH_LABELS[graph]}: ${marker.text}`);
|
||||
}
|
||||
}
|
||||
if (marker.language !== 'en') {
|
||||
for (const graph of ['lazyEditorFiles', 'lazyOnboardingFiles']) {
|
||||
if (text[graph].includes(marker.text)) {
|
||||
throw new Error(`${where} dictionary is static in ${GRAPH_LABELS[graph]} — it must be its own lazy chunk: ${marker.text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -472,10 +559,31 @@ export const LAZY_FURNITURE_ART_GZIP_CEILING = 17_900;
|
||||
* адаптер порта из 18 членов уникальны, а сжатие соседних `this.host._t(`
|
||||
* в рантайме их не покрывает. Старый центр оставлял 425 Б сверху, меньше
|
||||
* порога шума #593; новый — 1 025 Б сверху и 975 Б до нижней границы.
|
||||
* - #627: 246 000 → 229 500 (замер XXX). ru/de/fr словарей `settings`,
|
||||
* `support`, `topology` ушли из статического графа в девять ленивых чанков
|
||||
* (по одному на пару «пространство × язык», грузится только язык на экране);
|
||||
* в графе остался английский слой отката и загрузчик. Новая строка любого из
|
||||
* трёх словарей теперь стоит здесь ×1, а не ×4. Центр оставляет XXX Б
|
||||
* сверху и XXX Б до нижней границы полосы.
|
||||
*/
|
||||
export const LAZY_EDITOR_GZIP_CEILING = 246_000;
|
||||
export const LAZY_EDITOR_GZIP_CEILING = 229_500;
|
||||
export const LAZY_GRAPH_CEILING_BAND = 2_000;
|
||||
|
||||
/**
|
||||
* #627: граф первого запуска (форма «Пространство») впервые под потолком.
|
||||
* До этой задачи он только считался манифестом и не печатался даже в отчёт —
|
||||
* и вырос 13 918 → 34 526 Б (+148 %) незаметно: общая форма #600 утащила в
|
||||
* него form-kit, `hp-color-opacity` и четыре словаря `settings`.
|
||||
*
|
||||
* Потолок 28 500 (замер 27 470): ru/de/fr словаря `settings` — ленивые чанки,
|
||||
* в графе остались английский слой и загрузчик. Центр оставляет 1 030 Б
|
||||
* сверху и 970 Б до нижней границы полосы. Исходная цель ≤ 20 КБ этим не
|
||||
* достигается: остаток — form-kit, `space-form`, `hp-help` и стили набора
|
||||
* (форма #600), не словари; их вынос заметен пользователю (задержка на первом
|
||||
* «?») и в скоуп #627 не входит.
|
||||
*/
|
||||
export const LAZY_ONBOARDING_GZIP_CEILING = 28_500;
|
||||
|
||||
/**
|
||||
* Потолок ленивого графа: `null`, пока значение внутри полосы.
|
||||
*
|
||||
@@ -537,6 +645,7 @@ export function assertBundleBudget(
|
||||
panelOnlyBudget = INITIAL_PANEL_ONLY_GZIP_BUDGET,
|
||||
lazyFurnitureArtCeiling = LAZY_FURNITURE_ART_GZIP_CEILING,
|
||||
lazyEditorCeiling = LAZY_EDITOR_GZIP_CEILING,
|
||||
lazyOnboardingCeiling = LAZY_ONBOARDING_GZIP_CEILING,
|
||||
) {
|
||||
assertBundleManifest(manifest);
|
||||
if (!manifest.lazyEditorFiles?.length) {
|
||||
@@ -575,6 +684,22 @@ export function assertBundleBudget(
|
||||
|| manifest.lazyOnboardingFiles?.includes(path))) {
|
||||
throw new Error('lazy locale graph overlaps an editor graph');
|
||||
}
|
||||
// #627 AC3: ru/de/fr трёх словарей пространств — девять отдельных чанков,
|
||||
// ни один не входит ни в первый кадр, ни в статический граф редакторов.
|
||||
const namespaceLocales = manifest.lazyNamespaceLocaleFiles;
|
||||
if (!Array.isArray(namespaceLocales) || namespaceLocales.length !== NAMESPACE_LOCALE_CHUNKS.length) {
|
||||
throw new Error(`lazy namespace locale graph has ${namespaceLocales?.length ?? 0} files,`
|
||||
+ ` expected ${NAMESPACE_LOCALE_CHUNKS.length} (settings/support/topology × ru/de/fr)`);
|
||||
}
|
||||
for (const [graph, label] of [
|
||||
['initialViewFiles', 'initial View graph'],
|
||||
['lazyEditorFiles', 'lazy editor graph'],
|
||||
['lazyOnboardingFiles', 'lazy onboarding graph'],
|
||||
]) {
|
||||
if (namespaceLocales.some((path) => manifest[graph]?.includes(path))) {
|
||||
throw new Error(`${label} overlaps lazy namespace locale graph`);
|
||||
}
|
||||
}
|
||||
if (manifest.initialViewGzipBytes > budget) {
|
||||
throw new Error(
|
||||
`initial View graph ${manifest.initialViewGzipBytes} B gzip exceeds ${budget} B budget`,
|
||||
@@ -590,6 +715,8 @@ export function assertBundleBudget(
|
||||
for (const [bytes, ceiling, label] of [
|
||||
[manifest.lazyFurnitureArtGzipBytes, lazyFurnitureArtCeiling, 'lazy furniture art graph'],
|
||||
[manifest.lazyEditorGzipBytes, lazyEditorCeiling, 'lazy editor graph'],
|
||||
// #627: граф первого запуска — третий гейт, тем же правилом.
|
||||
[manifest.lazyOnboardingGzipBytes, lazyOnboardingCeiling, 'lazy onboarding graph'],
|
||||
]) {
|
||||
const violation = lazyGraphCeilingViolation(bytes, { ceiling, label });
|
||||
if (violation) throw new Error(violation.text);
|
||||
@@ -599,6 +726,8 @@ export function assertBundleBudget(
|
||||
initialPanelGzipBytes: manifest.initialPanelGzipBytes,
|
||||
initialPanelOnlyGzipBytes: manifest.initialPanelOnlyGzipBytes,
|
||||
lazyEditorGzipBytes: manifest.lazyEditorGzipBytes,
|
||||
lazyOnboardingGzipBytes: manifest.lazyOnboardingGzipBytes,
|
||||
lazyNamespaceLocaleGzipBytes: manifest.lazyNamespaceLocaleGzipBytes,
|
||||
lazyLocaleGzipBytes: manifest.lazyLocaleGzipBytes,
|
||||
lazyIsometricGzipBytes: manifest.lazyIsometricGzipBytes,
|
||||
lazyFurnitureArtGzipBytes: manifest.lazyFurnitureArtGzipBytes,
|
||||
@@ -611,6 +740,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
const manifest = JSON.parse(readFileSync(resolve('dist/houseplan-assets.json'), 'utf8'));
|
||||
const result = assertBundleBudget(manifest);
|
||||
assertSupportBundleOwnership(manifest);
|
||||
assertNamespaceLocaleOwnership(manifest);
|
||||
const ceiling = initialViewCeilingViolation(result.initialViewGzipBytes);
|
||||
if (ceiling) throw new Error(ceiling.text);
|
||||
const headroom = INITIAL_VIEW_GZIP_BUDGET - result.initialViewGzipBytes;
|
||||
@@ -623,6 +753,10 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
+ ` (budget ${INITIAL_PANEL_ONLY_GZIP_BUDGET} B,`
|
||||
+ ` headroom ${INITIAL_PANEL_ONLY_GZIP_BUDGET - result.initialPanelOnlyGzipBytes} B)`,
|
||||
`lazy editor: ${result.lazyEditorGzipBytes} B gzip (потолок ${LAZY_EDITOR_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy onboarding: ${result.lazyOnboardingGzipBytes} B gzip`
|
||||
+ ` (потолок ${LAZY_ONBOARDING_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy namespace locales: ${result.lazyNamespaceLocaleGzipBytes} B gzip`
|
||||
+ ` (${NAMESPACE_LOCALE_CHUNKS.length} chunks, по одному грузится на пространство)`,
|
||||
`lazy furniture art: ${result.lazyFurnitureArtGzipBytes} B gzip`
|
||||
+ ` (потолок ${LAZY_FURNITURE_ART_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`,
|
||||
|
||||
@@ -12,6 +12,22 @@ const FR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FR_RETRY_ASSET__';
|
||||
const FURNITURE_ART_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__';
|
||||
const PDF_RETRY_ASSET_TOKEN = '__HOUSEPLAN_PDF_RETRY_ASSET__';
|
||||
|
||||
/**
|
||||
* #627: ru/de/fr of the three lazy dictionary namespaces are one chunk per
|
||||
* namespace × language. Their loader modules are the recognition marker for
|
||||
* the manifest graph and the target of each second-attempt retry token.
|
||||
*/
|
||||
export const NAMESPACE_LOCALE_CHUNKS = ['settings', 'support', 'topology']
|
||||
.flatMap((namespace) => ['ru', 'de', 'fr'].map((language) => ({
|
||||
namespace,
|
||||
language,
|
||||
module: `/src/i18n/${namespace}/${namespace}-${language}.ts`,
|
||||
token: `__HOUSEPLAN_${namespace.toUpperCase()}_${language.toUpperCase()}_RETRY_ASSET__`,
|
||||
})));
|
||||
|
||||
const isNamespaceLocaleModule = (id) => NAMESPACE_LOCALE_CHUNKS
|
||||
.some((chunk) => id.endsWith(chunk.module));
|
||||
|
||||
export const CARD_ENTRY_FILE = 'houseplan-card.js';
|
||||
export const PANEL_ENTRY_FILE = 'houseplan-panel.js';
|
||||
|
||||
@@ -84,6 +100,8 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
const modules = Object.keys(chunk.modules || {}).map((id) => id.replaceAll('\\', '/'));
|
||||
const role = modules.some((id) => id.endsWith('/src/i18n/de.ts') || id.endsWith('/src/i18n/fr.ts'))
|
||||
? 'locale'
|
||||
: modules.some(isNamespaceLocaleModule)
|
||||
? 'namespace-locale'
|
||||
: modules.some((id) => id.endsWith('/src/houseplan-onboarding-runtime.ts'))
|
||||
? 'onboarding'
|
||||
: modules.some((id) => id.endsWith('/src/houseplan-editor-runtime.ts'))
|
||||
@@ -135,6 +153,10 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
const furnitureArtRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'furniture-art');
|
||||
const pdfRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'pdf'
|
||||
|| path.includes('pdf-export-'));
|
||||
// #627: namespace dictionaries are dynamic imports of LAZY chunks, never of
|
||||
// the initial graph, so they are found by their module, not as a root.
|
||||
const namespaceLocaleRoots = files.filter((file) => file._role === 'namespace-locale')
|
||||
.map((file) => file.path);
|
||||
const graphFrom = (roots) => {
|
||||
const graph = new Set();
|
||||
for (const root of roots) {
|
||||
@@ -148,6 +170,7 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
const lazyIsometric = graphFrom(isometricRoots);
|
||||
const lazyFurnitureArt = graphFrom(furnitureArtRoots);
|
||||
const lazyPdf = graphFrom(pdfRoots);
|
||||
const lazyNamespaceLocale = graphFrom(namespaceLocaleRoots);
|
||||
const sum = (paths) => [...paths]
|
||||
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
|
||||
return {
|
||||
@@ -175,6 +198,8 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
lazyFurnitureArtGzipBytes: sum(lazyFurnitureArt),
|
||||
lazyPdfFiles: [...lazyPdf].sort(),
|
||||
lazyPdfGzipBytes: sum(lazyPdf),
|
||||
lazyNamespaceLocaleFiles: [...lazyNamespaceLocale].sort(),
|
||||
lazyNamespaceLocaleGzipBytes: sum(lazyNamespaceLocale),
|
||||
files: files.map(({ _role, ...file }) => file),
|
||||
};
|
||||
}
|
||||
@@ -239,6 +264,14 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
if (!french) throw new Error('French locale chunk was not emitted');
|
||||
if (!furnitureArt) throw new Error('furniture artwork chunk was not emitted');
|
||||
if (!pdf) throw new Error('PDF export runtime chunk was not emitted');
|
||||
const namespaceLocales = NAMESPACE_LOCALE_CHUNKS.map((entry) => {
|
||||
const chunk = chunks.find((candidate) => Object.keys(candidate.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith(entry.module)));
|
||||
if (!chunk) {
|
||||
throw new Error(`${entry.namespace} ${entry.language} locale chunk was not emitted`);
|
||||
}
|
||||
return { ...entry, chunk, replacements: 0 };
|
||||
});
|
||||
let furnitureArtReplacements = 0;
|
||||
let editorReplacements = 0;
|
||||
let onboardingReplacements = 0;
|
||||
@@ -289,6 +322,13 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
pdfReplacements += chunk.code.split(PDF_RETRY_ASSET_TOKEN).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(PDF_RETRY_ASSET_TOKEN, asset);
|
||||
}
|
||||
for (const entry of namespaceLocales) {
|
||||
if (!chunk.code.includes(entry.token)) continue;
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), entry.chunk.fileName);
|
||||
if (!asset.startsWith('.')) asset = `./${asset}`;
|
||||
entry.replacements += chunk.code.split(entry.token).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(entry.token, asset);
|
||||
}
|
||||
}
|
||||
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|
||||
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1
|
||||
@@ -296,6 +336,14 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
throw new Error('lazy retry URL placeholder counts are '
|
||||
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}, expected 1/1/1/1/1/1/1`);
|
||||
}
|
||||
// #627: the same strict rule for every namespace × language token —
|
||||
// exactly one second-attempt URL each, never zero and never two.
|
||||
const namespaceMismatch = namespaceLocales.filter((entry) => entry.replacements !== 1);
|
||||
if (namespaceMismatch.length) {
|
||||
throw new Error('namespace locale retry URL placeholder counts are '
|
||||
+ namespaceMismatch.map((entry) => `${entry.namespace}-${entry.language}=${entry.replacements}`).join(', ')
|
||||
+ ', expected exactly 1 each');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2478,6 +2478,63 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " : await import(/* @vite-ignore */ new URL(`${FRENCH_RETRY_ASSET}?retry`, import.meta.url).href);\n void module.dictionary;\n return { dictionary: en, fingerprint: module.fingerprint };",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'i18n-namespace-eager-locale',
|
||||
guard: 'node --test test/i18n-lazy-namespaces.test.mjs',
|
||||
because: 'a static import of one ru/de/fr dictionary of a namespace puts all of it back into '
|
||||
+ 'the static editor and onboarding graphs — every new string costs ×4 again, the exact '
|
||||
+ 'growth #627 removes (13.9 → 34.5 KB gzip of first-run onboarding)',
|
||||
patches: [{
|
||||
file: 'src/i18n/settings.ts',
|
||||
find: "import en from './settings/en.json' with { type: 'json' };\n",
|
||||
replace: "import en from './settings/en.json' with { type: 'json' };\n"
|
||||
+ "import ru from './settings/ru.json' with { type: 'json' };\n",
|
||||
}, {
|
||||
file: 'src/i18n/settings.ts',
|
||||
find: ' SETTINGS_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key];',
|
||||
replace: " (lang === 'ru' ? ru[key] : SETTINGS_LANGUAGE_RUNTIME.dictionary(lang)?.[key]) ?? en[key];",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'locale-gate-ignores-namespaces',
|
||||
guard: 'node --test --test-name-pattern="#627 AC5" test/i18n-runtime.test.mjs',
|
||||
because: 'a host gate that asks only the main catalog paints the open settings dialog in '
|
||||
+ 'English for one frame after a live language switch — the flash #348 forbids, now for '
|
||||
+ 'the lazy namespace dictionaries (#627 AC5)',
|
||||
patches: [{
|
||||
file: 'src/i18n/namespace-language.ts',
|
||||
find: ' state: (code) => {\n'
|
||||
+ ' const state = primary.state(code);\n'
|
||||
+ " return state !== 'pending' && namespaces.some((runtime) => runtime.state(code) === 'pending')\n"
|
||||
+ " ? 'pending' : state;\n"
|
||||
+ ' },',
|
||||
replace: ' state: (code) => primary.state(code),',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'onboarding-loader-skips-namespace-ensure',
|
||||
guard: 'node demo/smoke_lazy_admin_locale.mjs',
|
||||
because: 'an onboarding runtime installed before its settings dictionary settled forces the '
|
||||
+ 'host into the warm branch: the first-run form waits behind an inert, busy card instead '
|
||||
+ 'of the existing runtime indicator — a new interface state the contract rules out (#627 AC4)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' if (module.ONBOARDING_RUNTIME_FINGERPRINT === ENTRY_BUILD_FINGERPRINT) await module.ONBOARDING_LANGUAGE_RUNTIME.ensure(langOf(this.hass, this._config?.language)); // #627\n',
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'namespace-loader-returns-english',
|
||||
guard: 'node demo/smoke_lazy_admin_locale.mjs',
|
||||
because: 'a German administrator silently reading the English settings dialog is invisible '
|
||||
+ 'to parity units — only the bundle smoke proves the de loader commits the de chunk (#627 AC6)',
|
||||
patches: [{
|
||||
file: 'src/i18n/settings.ts',
|
||||
find: " : import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_SETTINGS_DE_RETRY_ASSET__'))),\n",
|
||||
replace: " : import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_SETTINGS_DE_RETRY_ASSET__')))\n"
|
||||
+ ' .then((module) => ({ dictionary: en, fingerprint: module.fingerprint })),\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'static-glow-light-cache-spread',
|
||||
guard: 'node --test test/space-render-caches.test.mjs',
|
||||
@@ -2668,8 +2725,9 @@ const MUTANT_DEFINITIONS = [
|
||||
find: " /** Synchronize host/runtime language state and return the current branch. */\n"
|
||||
+ ' private _syncDangerConfirmLocaleGate(): LanguageRenderGate {\n'
|
||||
+ " if (!this._config || !this.hass) return 'ready';\n"
|
||||
+ ' return languageRenderGate(\n'
|
||||
+ ' this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),\n'
|
||||
+ ' return languageRenderGate( // #627: + dictionaries of the surfaces loaded on THIS host\n'
|
||||
+ ' this, this._editorRuntime?.languageRuntime ?? this._onboardingRuntime?.languageRuntime ?? LANGUAGE_RUNTIME,\n'
|
||||
+ ' langOf(this.hass, this._config.language),\n'
|
||||
+ ' );\n'
|
||||
+ ' }',
|
||||
replace: " private _dangerConfirmLocaleGate: LanguageRenderGate = 'ready';\n"
|
||||
@@ -2681,7 +2739,8 @@ const MUTANT_DEFINITIONS = [
|
||||
find: ' const localeGate = this._syncDangerConfirmLocaleGate();\n'
|
||||
+ " if (localeGate === 'cold') return languageLoadingTemplate();",
|
||||
replace: ' const localeGate = languageRenderGate(\n'
|
||||
+ ' this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),\n'
|
||||
+ ' this, this._editorRuntime?.languageRuntime ?? this._onboardingRuntime?.languageRuntime ?? LANGUAGE_RUNTIME,\n'
|
||||
+ ' langOf(this.hass, this._config.language),\n'
|
||||
+ ' );\n'
|
||||
+ ' this._dangerConfirmLocaleGate = localeGate;\n'
|
||||
+ " if (localeGate === 'cold') return languageLoadingTemplate();",
|
||||
@@ -8133,8 +8192,8 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'dictionary, not of the resolved string (#459 AC2)',
|
||||
patches: [{
|
||||
file: 'src/i18n/topology.ts',
|
||||
find: ' const value = DICTIONARIES[lang]?.[key] ?? en[key];',
|
||||
replace: ' const value = DICTIONARIES[lang]?.[key] ?? en[key] ?? key;',
|
||||
find: ' const value = TOPOLOGY_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key];',
|
||||
replace: ' const value = TOPOLOGY_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key] ?? key;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -9524,6 +9583,18 @@ const MUTANT_DEFINITIONS = [
|
||||
+ ' if (Number.isFinite(bytes)) return null; // mutant',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'bundle-budget-onboarding-ceiling-dropped',
|
||||
guard: 'node --test --test-name-pattern="#627 AC1" test/bundle-assets.test.mjs',
|
||||
because: '#627: the first-run graph grew 13.9 → 34.5 KB gzip unnoticed because it was only '
|
||||
+ 'counted, never compared. Dropping it from the ceiling loop returns exactly that state — '
|
||||
+ 'a declared ceiling nobody enforces',
|
||||
patches: [{
|
||||
file: 'scripts/bundle-budget.mjs',
|
||||
find: " [manifest.lazyOnboardingGzipBytes, lazyOnboardingCeiling, 'lazy onboarding graph'],\n",
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'furniture-art-eager-import',
|
||||
guard: 'npm run build && node scripts/bundle-budget.mjs',
|
||||
|
||||
@@ -283,6 +283,17 @@ export const SMOKE_LINKS = [
|
||||
+ 'requests and four root render gates: neutral cold frame, atomic German commit, page-cache '
|
||||
+ 'reuse and the bounded retry-to-English path cannot be proven from pure registry tests (#348)',
|
||||
},
|
||||
{
|
||||
symbols: [
|
||||
'namespaceLanguageRuntime', 'composeLanguageRuntimes', 'surfaceLanguageRuntime',
|
||||
'SETTINGS_LANGUAGE_RUNTIME', 'SUPPORT_LANGUAGE_RUNTIME', 'TOPOLOGY_LANGUAGE_RUNTIME',
|
||||
'EDITOR_LANGUAGE_RUNTIME', 'ONBOARDING_LANGUAGE_RUNTIME', 'settingsT', 'supportT', 'topologyT',
|
||||
],
|
||||
smokes: ['smoke_lazy_admin_locale.mjs', 'smoke_dialog_segments_i18n.mjs'],
|
||||
because: 'the ru/de/fr namespace dictionaries are separate lazy chunks: only the production '
|
||||
+ 'bundle shows the per-frame dialog text on a cold open, the held frame on a live switch, '
|
||||
+ 'the bounded retry-to-English path and the exact chunk requests of each surface (#627)',
|
||||
},
|
||||
{
|
||||
symbols: [
|
||||
'probeBackdrop', 'classifyPlanFile', 'downscaleBackdrop', 'renderBackdropGuard',
|
||||
|
||||
Reference in New Issue
Block a user