diff --git a/.changeset/22044-shared-entry-drops-conversion-table.md b/.changeset/22044-shared-entry-drops-conversion-table.md new file mode 100644 index 00000000000..ad8e1965bd4 --- /dev/null +++ b/.changeset/22044-shared-entry-drops-conversion-table.md @@ -0,0 +1,11 @@ +--- +'@objectstack/spec': patch +--- + +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 + +Clause-②: no + +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`. + +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. diff --git a/packages/spec/scripts/conversions-major18-merge.test.ts b/packages/spec/scripts/conversions-major18-merge.test.ts index d5a3bb6b698..8b40365cdb1 100644 --- a/packages/spec/scripts/conversions-major18-merge.test.ts +++ b/packages/spec/scripts/conversions-major18-merge.test.ts @@ -50,8 +50,8 @@ const BLANK = /[ \t]*\n/y; const DEFINITION = /^(?:export )?const ([A-Za-z_$][\w$]*): MetadataConversion = \{\n id: '([^'\n]+)',$/gm; /** Where a conversion that sorts last is defined: directly above this declaration's doc comment. */ const TAIL_ANCHOR = '\ninterface OrderedConversion {\n'; -/** How `CONVERSIONS_BY_MAJOR` reads the entries. */ -const WIRING = ' 18: inApplicationOrder(MAJOR_18_CONVERSIONS),\n'; +/** How `CONVERSIONS_BY_MAJOR` reads the entries (the annotation: see that table's docblock). */ +const WIRING = ' 18: /* @__PURE__ */ inApplicationOrder(MAJOR_18_CONVERSIONS),\n'; /** * The entries that predate the placement rule: their definitions stay where diff --git a/packages/spec/scripts/pure-schema-construction.test.ts b/packages/spec/scripts/pure-schema-construction.test.ts index ff078a8a4a5..b741f8a654b 100644 --- a/packages/spec/scripts/pure-schema-construction.test.ts +++ b/packages/spec/scripts/pure-schema-construction.test.ts @@ -17,6 +17,7 @@ import { build } from 'tsup'; import { afterAll, describe, expect, it } from 'vitest'; import { annotatePureCalls, mainConfig, pureSchemaConstruction } from '../tsup.config'; +import { ALL_CONVERSIONS } from '../src/conversions/registry'; import * as sourceMigrations from '../src/migrations/index'; /** 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' }); }); +// `./shared` reaches the conversion table only through `normalizeStackInput`, +// and its two load-time calls are marked pure in the source +// (`src/conversions/registry.ts`, the `CONVERSIONS_BY_MAJOR` docblock), so a +// consumer that keeps no reader of the table keeps no conversion (#22044). +// The consumer below imports what objectui's console imports from `./shared`; +// the second one imports the reader, and is the control that the instrument +// sees the table whenever a bundle keeps it. +describe('a consumer of the built ./shared entry keeps the conversion table only when it reads it', () => { + it('drops every conversion for the console names, keeps them all for normalizeStackInput', async () => { + const { file } = await buildEntry(path.join(PKG_DIR, 'src', 'shared', 'index.ts'), 'shared'); + const ids = ALL_CONVERSIONS.map((c) => c.id); + const kept = async (names: string, outName: string): Promise => { + const consumer = path.join(WORK_DIR, `${outName}.ts`); + writeFileSync(consumer, `export { ${names} } from ${JSON.stringify(file)};\n`); + const { text } = await buildEntry(consumer, outName); + return ids.filter((id) => text.includes(`'${id}'`) || text.includes(`"${id}"`)); + }; + + expect(ids.length).toBeGreaterThan(100); + expect(await kept('normalizeStackInput', 'shared-reader')).toEqual(ids); + expect( + await kept( + 'EVALUATED_EXPRESSION_SOURCE_REQUIRED, EvaluatedExpressionInputSchema, EvaluatedExpressionSchema, ValueDomainSchema, canonicalMetaUrlType', + 'shared-console', + ), + ).toEqual([]); + }); +}); + describe('annotatePureCalls — where the annotation goes', () => { it('hands back a file that names no marked constructor untouched, unparsed', () => { expect(annotatePureCalls('export const a = z.object({});\n', 'a.ts')).toBeUndefined(); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 9de2376ad3b..d707d83fdba 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -14832,6 +14832,23 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ * that retirements still add to is authored as sorted entries with an explicit * `order` ({@link MAJOR_18_CONVERSIONS}), so two retirements in flight do not * conflict here; the next major takes the same shape when it opens. + * + * ⚠️ EVERY CALL THIS TABLE AND {@link ALL_CONVERSIONS} MAKE AT MODULE LOAD + * CARRIES AN `@__PURE__` ANNOTATION — a major's `inApplicationOrder(…)` and + * the flattening below, and the next major's when it opens. Each published + * entry is one flat file (`tsup.config.ts`, `splitting: false`), so a + * consumer's bundler keeps every top-level call it cannot prove pure, together + * with everything that call references. Unmarked, these two initializers kept + * the whole table in every bundle of every entry whose graph reaches this + * module — every conversion, and the view, field, page-component, dashboard, + * chart and report schemas the conversions read. `./shared` is one of those + * entries: it reaches here only through `normalizeStackInput` + * (`shared/metadata-collection.zod.ts`), so a console importing an expression + * schema from it carried the table too (#22044). Marked, a bundle keeps the + * table only when something it keeps reads it. Both calls only sort, map and + * concatenate arrays this module owns. + * `packages/spec/scripts/pure-schema-construction.test.ts` holds the effect on + * the built `./shared` entry. */ export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], @@ -14904,11 +14921,14 @@ export const CONVERSIONS_BY_MAJOR: Readonly a - b) .flatMap((major) => CONVERSIONS_BY_MAJOR[major]!);