mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 05:41:34 +00:00
feat: арт мебели в ленивый чанк — стартовый граф 299 658 → 289 727 Б
Каталог (id, группа, размеры) остаётся eager; SVG-пути 44 дизайнерских символов — отдельный чанк за FurnitureArtRuntime (ready|pending|fallback, нонс на повторе, отпечаток сборки, осевший отказ). Запуск при приёме плана, бут-вуаль ждёт арт в пределах BOOT_MAX_MS, редактор отдаёт арт рантайму синхронно (adopt при загрузке чанка). Магнит проверяет каталог, не арт. Потолок initial View 300 500 → 290 500. Семь свидетелей, смок smoke_furniture_lazy_art, golden-harness требует все предметы после бута. Ядро −2 строки. Issue: #474 User-Visible: no
This commit is contained in:
@@ -150,8 +150,15 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
|
||||
* измеренный initial факт до 299 501 Б и оставила лишь 499 Б сверху — меньше
|
||||
* обязательного шумового запаса. Новый центр оставляет 999 Б сверху и
|
||||
* 1 001 Б снизу; продуктовый граф при этом не расширялся.
|
||||
*
|
||||
* 2026-09-06, #474: потолок опущен 300 500 → 290 500 без изменения общего
|
||||
* бюджета. Арт 44 дизайнерских символов мебели (`furniture-plan-art.generated`)
|
||||
* вынесен в ленивый чанк `lazyFurnitureArtFiles` (10 252 Б gzip): его грузят
|
||||
* только планы с мебелью, редактор отдаёт его рантайму синхронно. Измеренный
|
||||
* initial факт 289 690 Б оставляет 810 Б сверху и 1 190 Б до нижней границы
|
||||
* полосы; запас до стены 301 066 — 11 376 Б.
|
||||
*/
|
||||
export const INITIAL_VIEW_GZIP_CEILING = 300_500;
|
||||
export const INITIAL_VIEW_GZIP_CEILING = 290_500;
|
||||
export const INITIAL_VIEW_CEILING_BAND = 2_000;
|
||||
|
||||
/**
|
||||
@@ -268,6 +275,14 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
|
||||
if (!manifest.lazyIsometricFiles?.length) {
|
||||
throw new Error('bundle has no lazy isometric graph');
|
||||
}
|
||||
// #474: designer furniture artwork is lazy; a static import anywhere in the
|
||||
// View graph would pull ~10 KB gzip back into the initial graph silently.
|
||||
if (!manifest.lazyFurnitureArtFiles?.length) {
|
||||
throw new Error('bundle has no lazy furniture artwork graph');
|
||||
}
|
||||
if (manifest.initialViewFiles.some((path) => manifest.lazyFurnitureArtFiles.includes(path))) {
|
||||
throw new Error('initial View graph overlaps lazy furniture artwork graph');
|
||||
}
|
||||
if (manifest.initialViewFiles.some((path) => manifest.lazyEditorFiles.includes(path))) {
|
||||
throw new Error('initial View graph overlaps lazy editor graph');
|
||||
}
|
||||
@@ -291,6 +306,7 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
|
||||
lazyEditorGzipBytes: manifest.lazyEditorGzipBytes,
|
||||
lazyLocaleGzipBytes: manifest.lazyLocaleGzipBytes,
|
||||
lazyIsometricGzipBytes: manifest.lazyIsometricGzipBytes,
|
||||
lazyFurnitureArtGzipBytes: manifest.lazyFurnitureArtGzipBytes,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const ONBOARDING_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ONBOARDING_RETRY_ASSET__';
|
||||
const ISO_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ISO_RETRY_ASSET__';
|
||||
const DE_RETRY_ASSET_TOKEN = '__HOUSEPLAN_DE_RETRY_ASSET__';
|
||||
const FR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FR_RETRY_ASSET__';
|
||||
const FURNITURE_ART_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__';
|
||||
|
||||
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
@@ -39,7 +40,11 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
? 'editor'
|
||||
: modules.some((id) => id.endsWith('/src/iso-scene-render.ts'))
|
||||
? 'isometric'
|
||||
: undefined;
|
||||
// #474: designer furniture artwork — its own lazy chunk, shared by
|
||||
// the editor (static import) and the View runtime (dynamic).
|
||||
: modules.some((id) => id.endsWith('/src/furniture-plan-art.generated.ts'))
|
||||
? 'furniture-art'
|
||||
: undefined;
|
||||
return {
|
||||
path: chunk.fileName.replaceAll('\\', '/'),
|
||||
sha256: sha256(contents),
|
||||
@@ -71,6 +76,7 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
&& !localeRoots.includes(path) && !onboardingRoots.includes(path));
|
||||
const isometricRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'isometric'
|
||||
|| path.includes('iso-scene-render-'));
|
||||
const furnitureArtRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'furniture-art');
|
||||
const graphFrom = (roots) => {
|
||||
const graph = new Set();
|
||||
for (const root of roots) {
|
||||
@@ -82,6 +88,7 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
const lazyOnboarding = graphFrom(onboardingRoots);
|
||||
const lazyLocale = graphFrom(localeRoots);
|
||||
const lazyIsometric = graphFrom(isometricRoots);
|
||||
const lazyFurnitureArt = graphFrom(furnitureArtRoots);
|
||||
const sum = (paths) => [...paths]
|
||||
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
|
||||
return {
|
||||
@@ -100,6 +107,8 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
lazyLocaleGzipBytes: sum(lazyLocale),
|
||||
lazyIsometricFiles: [...lazyIsometric].sort(),
|
||||
lazyIsometricGzipBytes: sum(lazyIsometric),
|
||||
lazyFurnitureArtFiles: [...lazyFurnitureArt].sort(),
|
||||
lazyFurnitureArtGzipBytes: sum(lazyFurnitureArt),
|
||||
files: files.map(({ _role, ...file }) => file),
|
||||
};
|
||||
}
|
||||
@@ -153,11 +162,15 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith('/src/i18n/de.ts')));
|
||||
const french = chunks.find((chunk) => Object.keys(chunk.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith('/src/i18n/fr.ts')));
|
||||
const furnitureArt = chunks.find((chunk) => Object.keys(chunk.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith('/src/furniture-plan-art.generated.ts')));
|
||||
if (!editor) throw new Error('editor runtime chunk was not emitted');
|
||||
if (!onboarding) throw new Error('onboarding runtime chunk was not emitted');
|
||||
if (!isometric) throw new Error('isometric runtime chunk was not emitted');
|
||||
if (!german) throw new Error('German locale chunk was not emitted');
|
||||
if (!french) throw new Error('French locale chunk was not emitted');
|
||||
if (!furnitureArt) throw new Error('furniture artwork chunk was not emitted');
|
||||
let furnitureArtReplacements = 0;
|
||||
let editorReplacements = 0;
|
||||
let onboardingReplacements = 0;
|
||||
let isometricReplacements = 0;
|
||||
@@ -194,11 +207,17 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
frenchReplacements += chunk.code.split(FR_RETRY_ASSET_TOKEN).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(FR_RETRY_ASSET_TOKEN, asset);
|
||||
}
|
||||
if (chunk.code.includes(FURNITURE_ART_RETRY_ASSET_TOKEN)) {
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), furnitureArt.fileName);
|
||||
if (!asset.startsWith('.')) asset = `./${asset}`;
|
||||
furnitureArtReplacements += chunk.code.split(FURNITURE_ART_RETRY_ASSET_TOKEN).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(FURNITURE_ART_RETRY_ASSET_TOKEN, asset);
|
||||
}
|
||||
}
|
||||
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|
||||
|| germanReplacements !== 1 || frenchReplacements !== 1) {
|
||||
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1) {
|
||||
throw new Error('lazy retry URL placeholder counts are '
|
||||
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}, expected 1/1/1/1/1`);
|
||||
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}, expected 1/1/1/1/1/1`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,6 +8,9 @@ const PACK = path.join(ROOT, 'assets', 'furniture', 'houseplan-0.3.0');
|
||||
// Deliberately not named *manifest.json: HACS rejects a repository that
|
||||
// contains any second file matching that suffix outside the integration.
|
||||
const MANIFEST = path.join(PACK, 'pack.json');
|
||||
// #474: каталог (id, группа, категория, размеры) — в стартовом графе, арт
|
||||
// (SVG-пути, ~10 КБ gzip) — отдельный модуль, который View грузит лениво.
|
||||
const CATALOG_OUT = path.join(ROOT, 'src', 'furniture-plan-catalog.generated.ts');
|
||||
const PLAN_OUT = path.join(ROOT, 'src', 'furniture-plan-art.generated.ts');
|
||||
const MENU_OUT = path.join(ROOT, 'src', 'furniture-menu-art.generated.ts');
|
||||
const CHECK = process.argv.includes('--check');
|
||||
@@ -146,11 +149,18 @@ function loadPack() {
|
||||
}
|
||||
|
||||
const header = `/* Generated by scripts/generate-furniture-assets.mjs. DO NOT EDIT. */\n`;
|
||||
function renderCatalog(symbols) {
|
||||
const runtime = symbols.map(({ id, group, category, w, h }) => ({ id, group, category, w, h }));
|
||||
return header + `export const GENERATED_FURNITURE_CATALOG = ${JSON.stringify(runtime)} as const;\n`;
|
||||
}
|
||||
function renderPlan(symbols) {
|
||||
const runtime = symbols.map(({ id, group, category, w, h, art }) => ({
|
||||
id, group, category, w, h, art,
|
||||
}));
|
||||
return header + `export const GENERATED_FURNITURE_PLAN = ${JSON.stringify(runtime)} as const;\n`;
|
||||
const runtime = Object.fromEntries(symbols.map(({ id, art }) => [id, art]));
|
||||
return header
|
||||
+ '// Lazy chunk (#474): imported dynamically by furniture-art-runtime.ts and\n'
|
||||
+ '// statically by the editor. The fingerprint token is replaced at build time\n'
|
||||
+ '// so a chunk from another build is rejected instead of half-applied.\n'
|
||||
+ `export const FURNITURE_ART_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';\n`
|
||||
+ `export const GENERATED_FURNITURE_ART: Readonly<Record<string, { d: string; viewW: number; viewH: number }>> = ${JSON.stringify(runtime)};\n`;
|
||||
}
|
||||
function renderMenu(menu) {
|
||||
const runtime = menu.map(({ id, group, art }) => ({ id, group, art }));
|
||||
@@ -166,6 +176,7 @@ function output(file, content) {
|
||||
}
|
||||
|
||||
const pack = loadPack();
|
||||
output(CATALOG_OUT, renderCatalog(pack.symbols));
|
||||
output(PLAN_OUT, renderPlan(pack.symbols));
|
||||
output(MENU_OUT, renderMenu(pack.menu));
|
||||
console.log(`Furniture pack OK: ${pack.symbols.length} plan symbols, ${pack.menu.length} menu icons${CHECK ? ' (generated files current)' : ''}.`);
|
||||
|
||||
@@ -1434,10 +1434,10 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'a German user on a stale tab silently reading English is the N7 hole (#354 К2)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: " this._languageFailureUnsub = subscribeLanguageLoadFailures(() => {\n"
|
||||
find: " this._languageFailureUnsub = composeUnsub(subscribeLanguageLoadFailures(() => {\n"
|
||||
+ " this._showToast(this._t('toast.locale_load_failed'));\n"
|
||||
+ ' });',
|
||||
replace: ' this._languageFailureUnsub = undefined;',
|
||||
+ ' }), ',
|
||||
replace: ' this._languageFailureUnsub = composeUnsub(',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -6541,6 +6541,99 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' if (true',
|
||||
}],
|
||||
},
|
||||
// #474: designer furniture artwork is a lazy chunk. Each protective contract
|
||||
// of the runtime and its integration gets one witness.
|
||||
{
|
||||
id: 'furniture-art-eager-import',
|
||||
guard: 'npm run build && node scripts/bundle-budget.mjs',
|
||||
because: 'a static import of the artwork anywhere in the View graph silently pulls ~10 KB gzip '
|
||||
+ 'back into the initial graph — the exact debt #474 removes; the budget must see the chunk as lazy',
|
||||
patches: [{
|
||||
file: 'src/furniture.ts',
|
||||
find: "import { FURNITURE_ART_RUNTIME, type FurnitureArtHost } from './furniture-art-runtime';",
|
||||
replace: "import { FURNITURE_ART_RUNTIME, type FurnitureArtHost } from './furniture-art-runtime';\n"
|
||||
+ "import './furniture-plan-art.generated'; // mutant: eager artwork",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'furniture-art-fallback-never-settles',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="settle into fallback" test/furniture-art-runtime.test.mjs',
|
||||
because: 'fallback must be a SETTLED state: a runtime that stays pending after two failed attempts '
|
||||
+ 'keeps the boot gate waiting and re-imports forever (#352–#355 class)',
|
||||
patches: [{
|
||||
file: 'src/furniture-art-runtime.ts',
|
||||
find: ' if (this._art) return;\n this._failed = true;',
|
||||
replace: ' if (this._art) return;\n this._failed = false; // mutant: never settle',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'furniture-art-no-retry-nonce',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="first attempt failing and the second succeeding" test/furniture-art-runtime.test.mjs',
|
||||
because: 'Chromium caches a failed module forever; the second attempt must be a different URL '
|
||||
+ 'and must exist at all — a single attempt turns every transient failure into fallback',
|
||||
patches: [{
|
||||
file: 'src/furniture-art-runtime.ts',
|
||||
find: ' for (const attempt of [0, 1] as const) {\n try {\n const module = await this.options.load(attempt);',
|
||||
replace: ' for (const attempt of [0] as const) {\n try {\n const module = await this.options.load(attempt);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'furniture-art-boot-gate-ignored',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="boot gate waits only while pending" test/furniture-art-runtime.test.mjs',
|
||||
because: 'the boot veil must hold while a plan with furniture waits for its artwork; a gate that '
|
||||
+ 'never reports pending lets the first revealed frame miss every piece (#474 AC2)',
|
||||
patches: [{
|
||||
file: 'src/furniture-art-runtime.ts',
|
||||
find: " return runtime.state() === 'pending' && configNeedsFurnitureArt(config, isDesigner);",
|
||||
replace: ' return false; // mutant: veil never waits for artwork',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'furniture-placement-needs-art',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="wall magnet places a designer piece" test/furniture-art-runtime.test.mjs',
|
||||
because: 'the magnet needs the catalogue, not the artwork: checking the artwork refuses every '
|
||||
+ 'designer placement while the chunk is pending (#474 AC6)',
|
||||
patches: [{
|
||||
file: 'src/furniture-placement.ts',
|
||||
find: ' if (!furnitureSymbol(symbol) || !(canvasW > 0) || !(canvasH > 0)',
|
||||
replace: " if (!(furnitureSymbol(symbol) && (furnitureSymbol(symbol)?.g || FURNITURE_ART_RUNTIME.art(symbol))) || !(canvasW > 0) || !(canvasH > 0)",
|
||||
}, {
|
||||
file: 'src/furniture-placement.ts',
|
||||
find: "import { clampFurnSize, cmToNorm, furnitureSymbol } from './furniture';",
|
||||
replace: "import { clampFurnSize, cmToNorm, furnitureSymbol } from './furniture';\nimport { FURNITURE_ART_RUNTIME } from './furniture-art-runtime';",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'furniture-art-fingerprint-unchecked',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="foreign build fingerprint is terminal|adopt is synchronous, rejects" test/furniture-art-runtime.test.mjs',
|
||||
because: 'artwork from another build must be rejected on both paths (load and adopt): a stale '
|
||||
+ 'chunk half-applied to a newer card is the #353 failure mode',
|
||||
patches: [{
|
||||
file: 'src/furniture-art-runtime.ts',
|
||||
find: ' if (module.FURNITURE_ART_FINGERPRINT !== this.options.expectedFingerprint) {',
|
||||
replace: ' if (false) { // mutant: any build will do',
|
||||
}, {
|
||||
file: 'src/furniture-art-runtime.ts',
|
||||
find: ' if (fingerprint !== this.options.expectedFingerprint) {',
|
||||
replace: ' if (false) { // mutant: any build will do',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'furniture-art-editor-adopt-skipped',
|
||||
guard: 'npm run bundle:sync && node demo/smoke_furniture.mjs',
|
||||
because: 'the editor imports the artwork statically and must hand it over synchronously; without '
|
||||
+ 'adopt the palette previews and the placement ghost render empty on a plan without furniture (#474 r1)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-editor-runtime.ts',
|
||||
find: 'FURNITURE_ART_RUNTIME.adopt(GENERATED_FURNITURE_ART, FURNITURE_ART_FINGERPRINT);',
|
||||
replace: 'void [FURNITURE_ART_RUNTIME, GENERATED_FURNITURE_ART, FURNITURE_ART_FINGERPRINT]; // mutant: no handover',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'iso-aabb-rejects-touching-wall',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
|
||||
Reference in New Issue
Block a user