Skip to content

Commit 93125ae

Browse files
perf(spec): a bundle that never reads the ADR-0087 conversion table stops keeping it, 226 KB gzip off the console first screen (#22044) (#22048)
Fixes #22044 Clause-②: no This is the source-side payback for the maintainer's ruling on objectui#11717. The record is comment `6027998226`, and the selection was 「A 抬预算+回收卡 (Recommended)」. objectui raised its console first-screen budget to let 17.7.0 in (objectui#11742), and this card brings it back down. **Measured on objectui's console at objectui `c0862c1c`, which resolves 17.7.0:** the first screen's eager closure is **226,238 bytes gzip smaller**, and all of the change is in the `vendor-objectstack` chunk. The 17.7.0 bump added +182,909 bytes gzip to that chunk. No entry's export list changes, and no declaration or runtime value changes. Only the bytes a bundler keeps change. ## What grew between the tags, module by module **Instrument:** source-map attribution of each published ESM entry file. The tarball ships an `.mjs.map` for each entry, and each generated byte is assigned to the source module of the mapping it falls under. The comparison is npm `@objectstack/spec@17.6.0` against `@objectstack/spec@17.7.0`. All figures are raw bytes. **`dist/shared/index.mjs`:** 980,505 to 1,503,348 bytes (+522,843), with the same 79 exports. | module in `./shared` | 17.6.0 | 17.7.0 | delta | | --- | --: | --: | --: | | `src/ui/component.zod.ts` | 277,752 | 395,904 | +118,152 | | `src/ui/view.zod.ts` | 42,538 | 156,126 | +113,588 | | `src/data/field.zod.ts` | 11,426 | 122,268 | +110,842 | | `src/ui/dashboard.zod.ts` | 0 | 38,152 | +38,152 | | `src/conversions/registry.ts` | 357,075 | 391,897 | +34,822 | | `src/ui/chart.zod.ts` | 0 | 31,088 | +31,088 | | `src/ui/report.zod.ts` | 0 | 21,701 | +21,701 | | `src/ui/bulk-action.zod.ts` | 131 | 17,034 | +16,903 | | 32 other modules, net | | | +37,595 | **`dist/browser/index.mjs`** (the root a browser bundler loads): 2,348,750 to 2,593,955 bytes (+245,205). | module in the browser root | delta | | --- | --: | | `src/ui/component.zod.ts` | +118,152 | | `src/conversions/registry.ts` | +34,822 | | `src/automation/approval.zod.ts` | +23,623 | | `src/ai/agent.zod.ts` | +10,024 | | `src/system/job.zod.ts` | +10,022 | | `src/ui/chart.zod.ts` | +9,605 | | `src/ui/page.zod.ts` | +4,549 | | other modules, net | +34,408 | **How `./shared` reaches those modules.** This is the esbuild metafile of the 17.7.0 source, shortest path. `shared/index.ts` imports `shared/metadata-collection.zod.ts`, which imports `conversions/apply.ts` (for `normalizeStackInput`). That imports `conversions/registry.ts`, and from there the schemas the conversions read: - `ui/view.zod.ts`, then `chart.zod.ts` and `bulk-action.zod.ts`; - through `conversions/walk.ts`: `ui/component.zod.ts`, then `dashboard.zod.ts` and `report.zod.ts`. `data/field.zod.ts` is also reached through `shared/field-type-suggestion.ts`. The shared source itself grew very little: its own modules are under 10 KB of the delta. ## Why the console kept it **Every top-level call stays.** Each published entry is one flat file (`tsup.config.ts`, `splitting: false`). A consumer's bundler therefore keeps every top-level call it cannot prove pure, together with everything that call references. **Two calls build the conversion table at load:** - `18: inApplicationOrder(MAJOR_18_CONVERSIONS)`; - the `Object.keys(CONVERSIONS_BY_MAJOR)` chain that flattens into `ALL_CONVERSIONS`. Unmarked, they kept every conversion, and every schema a conversion reads, in every bundle of every entry whose graph reaches the table. They did so whether or not the bundle reads the table. **The console never reads the table, and it carried two copies.** In the control's `vendor-objectstack` chunk, each of the 129 conversion ids appears as an `id:` property exactly **2** times: the root's copy and `./shared`'s copy. In the candidate's chunk the count is **0**. That is why the recovery is larger than `./shared`'s growth alone. The table was already in both copies at 17.6.0, and 17.7.0's new conversions made each copy larger. **Consumer probe.** This probe isolates `./shared` from the rest of the chunk. It is not the acceptance instrument. Setup: - rolldown 1.0.3, platform browser, minified, gzip -9; - one bundle per spec entry, importing exactly the names objectui's non-test source at `c0862c1c` value-imports from that entry; - spec's own source maps chained, so each retained byte is attributed to its module. | entry | 17.6.0 | 17.7.0 | this head | | --- | --: | --: | --: | | `./shared` | 120,147 | 191,561 | **47,491** | | `./ui` | 132,642 | 165,099 | 165,156 | | root (browser) | 312,154 | 342,786 | 342,842 | | `./system` (browser) | 283,598 | 310,749 | 310,794 | | all 13 entries objectui imports | 1,140,385 | 1,315,136 | 1,171,318 | - In `./shared` at this head, `src/conversions/registry.ts` keeps 0 bytes, down from 201,720 at 17.7.0. - The root probe still keeps the table. Its name set includes `defineStack` / `composeStacks`, which `@object-ui/types` and `@object-ui/core` re-export, but nothing the console bundles calls either of them. The console reading above shows the root's copy gone as well. ## What changed - **`packages/spec/src/conversions/registry.ts`:** both load-time calls carry a `@__PURE__` annotation. The `CONVERSIONS_BY_MAJOR` docblock says why, says that the next major's `inApplicationOrder(…)` takes the same mark, and names the pin. No conversion, no order and no value changes. - **Why source annotations, not the `pure-schema-construction` build plugin.** The plugin marks calls by the name of the callee, everywhere under `src/`. The `ALL_CONVERSIONS` initializer is a `map` / `sort` / `flatMap` chain on `Object.keys`, and marking those names would mark every such call in the package. Two explicit marks on the two calls are the narrowest change. `tsup.config.ts` is untouched, so the chunk layout and the published file set are unchanged (below). - **Pin:** a new case in `packages/spec/scripts/pure-schema-construction.test.ts`. It builds `./shared` through tsup with the package's own main options, then builds two consumers of that output: - the names objectui's console imports from `./shared` keep **none** of the 129 conversion ids; - `normalizeStackInput`, the control, keeps **all** of them, which proves the instrument sees the table whenever a bundle keeps it. This is a test case, not a gate and not a byte ceiling. - **`packages/spec/scripts/conversions-major18-merge.test.ts`:** its `WIRING` string, a textual pin of the major-18 wiring line, now spells the annotation. - **`.changeset/22044-shared-entry-drops-conversion-table.md`:** `@objectstack/spec` `patch`. The published bytes change and the contract does not. ## Console reading: the acceptance instrument objectui `c0862c1c` in a detached worktree, built the way the `Bundle Analysis` job builds it: ```text pnpm install --frozen-lockfile pnpm turbo run build --filter='./packages/*' pnpm --filter @object-ui/console build ``` Each reading comes from `apps/console/dist/eager-closure.json`, which reports gzip bytes. | build | eager closure gzip | `vendor-objectstack` gzip | `vendor-objectstack` raw | table copies in the chunk | | --- | --: | --: | --: | --: | | (a) control: the installed npm 17.7.0 | 3,591,131 | 1,604,474 | 5,399,508 | 2 | | (b) `OBJECTSTACK_SPEC_DIST` set to `main` `9a0401fd`, built | 3,591,379 | 1,604,719 | 5,400,408 | not counted | | (c) `OBJECTSTACK_SPEC_DIST` set to this head `eb2152b86b`, built | **3,365,141** | **1,378,418** | 4,522,189 | **0** | - **Recovered, (b) − (c), same injection path and same objectui commit:** 226,238 gzip on the eager closure, and 226,301 gzip / 878,219 raw on `vendor-objectstack`. The other 330 eager chunks moved by +63 gzip net (import-hash churn), and the eager chunk count is 331 in all three builds. - **Against the published control, (a) − (c):** 225,990 gzip. - **Repeatability:** (a) and (c) were each built twice and read the same both times. - **Why `OBJECTSTACK_SPEC_DIST`:** it is objectui's own injection, the one the Console Pin Gate uses. Reading (b) shows it moves the control by only +248 gzip, which is `main`'s drift since the tag plus the injection path. ## Export parity The guard is that the export surface does not change. Every comparison below sets `main` `9a0401fd` (built in its own worktree) against this head (built): - **`pnpm --filter @objectstack/spec check:api-surface`:** exit 0, "public API surface + factory signatures unchanged ✓". - **Runtime export lists:** `Object.keys` of every `.mjs` and `.js` file the exports map names, browser arms included. 48 entry files and 7,864 names, **0 differing**. Control leg: npm 17.6.0 against this head reads 16 differing (exit 1). - **Declaration tree:** compared in the order-insensitive form the `tsup.config.ts` DTS docblock prescribes (sort union members, property-only type-literal members, statements and specifiers; strip chunk hashes; pair chunks by content). 130 files, 38 entries and 62 chunk contents on each side: **identical**. Control leg: npm 17.6.0 against 17.7.0 reads 52 differences (exit 1). - **Published file set:** `dist/` holds 228 files on both sides, with chunk hashes stripped, and the lists are identical. The chunk layout does not move, so `publish-smoke` is not owed and `needs:pack-smoke` is not needed. - **Per-entry bytes:** each entry file that carries the table grows by 32 raw bytes, the two annotations. For example, `dist/shared/index.mjs` goes from 1,510,391 to 1,510,423 bytes. ## Verification record The final head is `eb2152b86b`. Its last commit adds only the changeset, so readings taken at `08392e53a4` cover the same `src/` and `scripts/` tree. - **Reverse verification of the pin.** Both annotations were committed first, then each was removed in turn through `scripts/ablation-replace.mjs`, at `08392e53a4`: - removing the `ALL_CONVERSIONS` mark ("mutation landed", anchor count 1 to 0) turned the pin red, with 129 ids kept against the expected `[]`. Restore: "blob == HEAD (d707d83) and `git diff HEAD` is empty". - removing the `inApplicationOrder` mark turned it red with the 62 major-18 ids kept. Restored the same way. - The pin builds from `src/` through tsup, so no `dist/` sits between the mutation and the assertion. - **Tests:** | suite | head | result | | --- | --- | --- | | `@objectstack/spec` local project, full (`pnpm --filter @objectstack/spec test`) | `eb2152b86b` | 619 files, 18,486 passed, 1 todo | | `scripts/pure-schema-construction.test.ts` | `08392e53a4` | 7 passed (the new case included) | | `scripts/conversions-major18-merge.test.ts`, repo project | `08392e53a4` | 12 passed | The rest of the `repo` project is declared to CI. - **Typecheck:** `pnpm --filter @objectstack/spec typecheck` (tsc, scripts and test layer) passed at `eb2152b86b`. - **Build:** `pnpm --filter @objectstack/spec build` (the JS and DTS passes, `check-dts-emitted`, `check-dts-references`) exited 0 at `d017de4582` and at `eb2152b86b`. - **Gates**, at `eb2152b86b`: - `node scripts/pm/dispatch-gates.mjs --commands` derives 86 families. `--ran` reconciles them: 86 run, 0 NOT-MEASURED, 0 UNRUN. Every recorded exit code is 0. - Three of the 86 first answered PREREQUISITE NOT MET (exit 3) and were re-run green after `pnpm turbo run build`: `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `@objectstack/lint` `check:doc-formula-expressions`. - Also exit 0: `pnpm --filter @objectstack/spec check:generated` ("All 15 generated artifacts are up to date"). - The five families the dispatch's lead list named beyond this derivation also exited 0: `check:authorable-surface`, `check:yaml-examples`, the `check-governed-queue-guard` self-test, `check:console-injection` and `check:console-sha`. The last two skipped for want of `packages/console/dist`, so they are **NOT MEASURED** here. - **Lint**, narrowed and proven, at `eb2152b86b`: - Scope: `eslint --no-inline-config --format json` over the 3 changed `.ts` files, which is every changed file in eslint's population (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`). The changeset is outside it. - Result: 3 files, 0 errors, 0 warnings. - Why the narrowing excludes nothing: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move any untouched file's verdict. The full-tree `pnpm lint` is CI's. ## Acceptance notes - **objectui's half.** Lowering the ceiling and `BASELINE` by the recovered amount is objectui's follow-up, once it resolves a release that carries this change. The reading to lower by is the (b) − (c) line above. No objectui commit, branch or PR was made: the objectui worktree was used for measurement only, and was removed. - **What is left in `./shared` after this change** (consumer probe: 144,609 raw / 47,491 gzip for the console's 5 names). Most of it is argument expressions of calls the build marks pure. A `strictObject({ … })` call is dropped when unused, but arguments that have side effects still run and are kept, for example bare `z.*` chains, `.optional().describe(…)` on an imported schema, and `componentSlot(…)`. - The largest remainders: `src/ui/component.zod.ts` 34,556, `src/ui/view.zod.ts` 18,973, `src/data/filter.zod.ts` about 18,000. - This is how the build marks construction, not something this card's scope covers. Noted, not filed. Carrier: none. - **Copies across entries.** In the per-entry probe, `src/ui/component.zod.ts` is kept at about 157 to 159 KB raw in each of `./ui`, the root and `./system`, because each flat entry carries its own copy. - Whether the console keeps more than one copy is NOT MEASURED: its build emits no module-level map. That would be the shared-chunk route, which redraws the published file set, and the `tsup.config.ts` docblock asks for that review first. - Noted, not filed. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7a48b7 commit 93125ae

4 files changed

Lines changed: 66 additions & 5 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
A browser bundle that imports from `@objectstack/spec/shared` or the package root no longer keeps the ADR-0087 conversion table unless it uses it
6+
7+
Clause-②: no
8+
9+
Each published entry is one flat file, so a consumer's bundler keeps every top-level call it cannot prove pure, together with everything that call references. Two such calls built the conversion table when the module loaded: the major-18 list's `inApplicationOrder(...)` and the flattening into `ALL_CONVERSIONS`. So every bundle of an entry that reaches the table kept all of it: every conversion, plus the view, field, page-component, dashboard, chart and report schemas the conversions read. `./shared` reaches the table only through `normalizeStackInput`, so a bundle that imported an expression schema from it carried the table too, and 17.7.0's new conversions made that copy larger. Both calls now carry a `@__PURE__` annotation, so a bundle keeps the table only when something it keeps reads it, for example `defineStack`, `normalizeStackInput` or `applyConversions`.
10+
11+
Measured on objectui's console (objectui `c0862c1c`), against the same build with this package's previous source: the first screen's eager closure is 226,238 bytes gzip smaller, all of it in the `vendor-objectstack` chunk. Every entry's export list, every declaration and every runtime value is unchanged. Only the bytes a bundler keeps change.

‎packages/spec/scripts/conversions-major18-merge.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ const BLANK = /[ \t]*\n/y;
5050
const DEFINITION = /^(?:export )?const ([A-Za-z_$][\w$]*): MetadataConversion = \{\n id: '([^'\n]+)',$/gm;
5151
/** Where a conversion that sorts last is defined: directly above this declaration's doc comment. */
5252
const TAIL_ANCHOR = '\ninterface OrderedConversion {\n';
53-
/** How `CONVERSIONS_BY_MAJOR` reads the entries. */
54-
const WIRING = ' 18: inApplicationOrder(MAJOR_18_CONVERSIONS),\n';
53+
/** How `CONVERSIONS_BY_MAJOR` reads the entries (the annotation: see that table's docblock). */
54+
const WIRING = ' 18: /* @__PURE__ */ inApplicationOrder(MAJOR_18_CONVERSIONS),\n';
5555

5656
/**
5757
* The entries that predate the placement rule: their definitions stay where

‎packages/spec/scripts/pure-schema-construction.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { build } from 'tsup';
1717
import { afterAll, describe, expect, it } from 'vitest';
1818

1919
import { annotatePureCalls, mainConfig, pureSchemaConstruction } from '../tsup.config';
20+
import { ALL_CONVERSIONS } from '../src/conversions/registry';
2021
import * as sourceMigrations from '../src/migrations/index';
2122

2223
/** This package's root — every read and write below stays inside it. */
@@ -114,6 +115,35 @@ describe('a fixture quoting every marked name survives the build byte-identical'
114115
});
115116
});
116117

118+
// `./shared` reaches the conversion table only through `normalizeStackInput`,
119+
// and its two load-time calls are marked pure in the source
120+
// (`src/conversions/registry.ts`, the `CONVERSIONS_BY_MAJOR` docblock), so a
121+
// consumer that keeps no reader of the table keeps no conversion (#22044).
122+
// The consumer below imports what objectui's console imports from `./shared`;
123+
// the second one imports the reader, and is the control that the instrument
124+
// sees the table whenever a bundle keeps it.
125+
describe('a consumer of the built ./shared entry keeps the conversion table only when it reads it', () => {
126+
it('drops every conversion for the console names, keeps them all for normalizeStackInput', async () => {
127+
const { file } = await buildEntry(path.join(PKG_DIR, 'src', 'shared', 'index.ts'), 'shared');
128+
const ids = ALL_CONVERSIONS.map((c) => c.id);
129+
const kept = async (names: string, outName: string): Promise<string[]> => {
130+
const consumer = path.join(WORK_DIR, `${outName}.ts`);
131+
writeFileSync(consumer, `export { ${names} } from ${JSON.stringify(file)};\n`);
132+
const { text } = await buildEntry(consumer, outName);
133+
return ids.filter((id) => text.includes(`'${id}'`) || text.includes(`"${id}"`));
134+
};
135+
136+
expect(ids.length).toBeGreaterThan(100);
137+
expect(await kept('normalizeStackInput', 'shared-reader')).toEqual(ids);
138+
expect(
139+
await kept(
140+
'EVALUATED_EXPRESSION_SOURCE_REQUIRED, EvaluatedExpressionInputSchema, EvaluatedExpressionSchema, ValueDomainSchema, canonicalMetaUrlType',
141+
'shared-console',
142+
),
143+
).toEqual([]);
144+
});
145+
});
146+
117147
describe('annotatePureCalls — where the annotation goes', () => {
118148
it('hands back a file that names no marked constructor untouched, unparsed', () => {
119149
expect(annotatePureCalls('export const a = z.object({});\n', 'a.ts')).toBeUndefined();

‎packages/spec/src/conversions/registry.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14832,6 +14832,23 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [
1483214832
* that retirements still add to is authored as sorted entries with an explicit
1483314833
* `order` ({@link MAJOR_18_CONVERSIONS}), so two retirements in flight do not
1483414834
* conflict here; the next major takes the same shape when it opens.
14835+
*
14836+
* ⚠️ EVERY CALL THIS TABLE AND {@link ALL_CONVERSIONS} MAKE AT MODULE LOAD
14837+
* CARRIES AN `@__PURE__` ANNOTATION — a major's `inApplicationOrder(…)` and
14838+
* the flattening below, and the next major's when it opens. Each published
14839+
* entry is one flat file (`tsup.config.ts`, `splitting: false`), so a
14840+
* consumer's bundler keeps every top-level call it cannot prove pure, together
14841+
* with everything that call references. Unmarked, these two initializers kept
14842+
* the whole table in every bundle of every entry whose graph reaches this
14843+
* module — every conversion, and the view, field, page-component, dashboard,
14844+
* chart and report schemas the conversions read. `./shared` is one of those
14845+
* entries: it reaches here only through `normalizeStackInput`
14846+
* (`shared/metadata-collection.zod.ts`), so a console importing an expression
14847+
* schema from it carried the table too (#22044). Marked, a bundle keeps the
14848+
* table only when something it keeps reads it. Both calls only sort, map and
14849+
* concatenate arrays this module owns.
14850+
* `packages/spec/scripts/pure-schema-construction.test.ts` holds the effect on
14851+
* the built `./shared` entry.
1483514852
*/
1483614853
export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConversion[]>> = {
1483714854
11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename],
@@ -14904,11 +14921,14 @@ export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConv
1490414921
appHiddenToUnpublished,
1490514922
actionGlobalNavLocationRemoved,
1490614923
],
14907-
18: inApplicationOrder(MAJOR_18_CONVERSIONS),
14924+
18: /* @__PURE__ */ inApplicationOrder(MAJOR_18_CONVERSIONS),
1490814925
};
1490914926

14910-
/** Flattened, deterministic list of every conversion the loader knows about. */
14911-
export const ALL_CONVERSIONS: readonly MetadataConversion[] = Object.keys(CONVERSIONS_BY_MAJOR)
14927+
/**
14928+
* Flattened, deterministic list of every conversion the loader knows about.
14929+
* `@__PURE__` for the reason {@link CONVERSIONS_BY_MAJOR} gives.
14930+
*/
14931+
export const ALL_CONVERSIONS: readonly MetadataConversion[] = /* @__PURE__ */ Object.keys(CONVERSIONS_BY_MAJOR)
1491214932
.map(Number)
1491314933
.sort((a, b) => a - b)
1491414934
.flatMap((major) => CONVERSIONS_BY_MAJOR[major]!);

0 commit comments

Comments
 (0)