Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/22044-shared-entry-drops-conversion-table.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/spec/scripts/conversions-major18-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions packages/spec/scripts/pure-schema-construction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string[]> => {
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();
Expand Down
26 changes: 23 additions & 3 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<number, readonly MetadataConversion[]>> = {
11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename],
Expand Down Expand Up @@ -14904,11 +14921,14 @@ export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConv
appHiddenToUnpublished,
actionGlobalNavLocationRemoved,
],
18: inApplicationOrder(MAJOR_18_CONVERSIONS),
18: /* @__PURE__ */ inApplicationOrder(MAJOR_18_CONVERSIONS),
};

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