Skip to content

Commit 06454c2

Browse files
SisyphusZhengDevBot
andauthored
fix(release,ui): pack @openelement/ui through the compiled-element intrinsic transform (#1301) (#1302)
The packed npm artifact of @openelement/ui could not be SSR-admitted via the documented packageIslands path: deno pack transpiles the component .tsx sources to .js with TC39 decorator lowering (applyDecs2203R), which erases the compile-time-only @element/@Property intrinsics (ADR-0143 — their runtime exports are inert no-ops by design). No Part Program registered from the packed modules, so SSG failed closed with OE_PROGRAM_MISSING. In-repo consumers never saw this because the adapter auto-aliases workspace members to source (workspace-alias.ts). The admission contract is unchanged. The pack pipeline now runs the same open:compiled-element intrinsic transform a consumer build would run: packages shipping compiled-element sources are packed from a staged temporary workspace whose component modules carry the compiler output, so deno pack transpiles compiled form (semantics-preserving) instead of lowering the intrinsics away. Adds consumer:packaged-ui, a CI-gated (ci + release tiers) packed-artifact consumer qualification: the five pack:dry-run tarballs are installed into a hermetic scratch consumer outside the repository, a minimal app admits @openelement/ui via packageIslands, and the prerendered HTML must carry the compiled DSD for <open-theme-toggle>. RED pre-fix (build fails, OE_PROGRAM_MISSING), GREEN post-fix. Co-authored-by: DevBot <devbot@openelement.dev>
1 parent b29894d commit 06454c2

6 files changed

Lines changed: 567 additions & 2 deletions

File tree

deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"stress:dogfood": "deno run --allow-read --allow-run --allow-env tools/run-dogfood-stress.ts",
8888
"dogfood:evidence": "deno run --allow-read --allow-write --allow-run tools/run-dogfood-evidence.ts",
8989
"package-artifacts:check": "deno run --allow-read --allow-write --allow-run --allow-net --allow-env tools/check-package-artifacts.ts",
90+
"consumer:packaged-ui": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/consumer-packaged-ui.ts",
9091
"pack": "deno task generate:ui-manifest && deno run --allow-read --allow-write --allow-run --allow-env tools/publish-npm.ts pack",
9192
"pack:dry-run": "deno task generate:ui-manifest && deno run --allow-read --allow-write --allow-run --allow-env tools/publish-npm.ts pack:dry-run",
9293
"publish:npm": "deno task generate:ui-manifest && deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/publish-npm.ts publish:npm",

tools/autoflow/policy.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,7 @@ const GATES: readonly GateDefinition[] = [
496496
/^tools\/check-package-artifacts\.ts$/,
497497
/^tools\/publish-npm\.ts$/,
498498
/^tools\/lib\/package-graph\.ts$/,
499+
/^tools\/lib\/compiled-pack-staging\.ts$/,
499500
],
500501
},
501502
{
@@ -510,6 +511,25 @@ const GATES: readonly GateDefinition[] = [
510511
/^deno\.json$/,
511512
],
512513
},
514+
{
515+
// #1301: qualify the PACKED @openelement/ui artifact through the
516+
// documented packageIslands admission path — workspace-source consumers
517+
// (www, ui-dogfood) cannot see packed-only defects because the adapter
518+
// auto-aliases workspace members to source. Ordered after
519+
// package-artifacts:check, which produces the tarballs it installs.
520+
name: 'consumer:packaged-ui',
521+
command: ['deno', 'task', 'consumer:packaged-ui'],
522+
tiers: ['ci', 'release'],
523+
triggers: [
524+
/^packages\/ui\//,
525+
/^packages\/element\//,
526+
/^packages\/adapter-vite\//,
527+
/^tools\/consumer-packaged-ui\.ts$/,
528+
/^tools\/publish-npm\.ts$/,
529+
/^tools\/lib\/compiled-pack-staging\.ts$/,
530+
/^deno\.json$/,
531+
],
532+
},
513533
{
514534
name: 'consumer:element-smoke',
515535
command: ['deno', 'task', 'consumer:element-smoke'],
@@ -559,6 +579,7 @@ const GATES: readonly GateDefinition[] = [
559579
/^deno\.json$/,
560580
/^tools\/publish-npm\.ts$/,
561581
/^tools\/lib\/package-graph\.ts$/,
582+
/^tools\/lib\/compiled-pack-staging\.ts$/,
562583
],
563584
},
564585
];

tools/consumer-packaged-ui.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/**
2+
* Packed-artifact consumer qualification for @openelement/ui (#1301).
3+
*
4+
* The observational rule for packaging defects: qualify the PACKED artifact,
5+
* never the workspace source. This tool installs the five pack:dry-run
6+
* tarballs into a scratch consumer OUTSIDE the repository (so the adapter's
7+
* workspace auto-alias in workspace-alias.ts cannot substitute workspace
8+
* source for the packed modules), builds a minimal app that admits
9+
* @openelement/ui through the documented `packageIslands` path, and asserts
10+
* the prerendered HTML carries the compiled DSD for <open-theme-toggle>.
11+
*
12+
* Pre-#1301 the packed ui modules lost their compile-time-only
13+
* @element/@property intrinsics to `deno pack` transpilation, no Part
14+
* Program registered, and the static prerender failed closed with
15+
* OE_PROGRAM_MISSING (route / -> 500, build error). Post-fix the packed
16+
* modules carry the compiler output and the build succeeds.
17+
*/
18+
19+
import { existsSync } from '@std/fs';
20+
import { join, resolve } from '@std/path';
21+
import { formatJson } from '@openelement/element/build-utils';
22+
import { PACKAGE_VERSION, RETAINED_PACKAGE_NAMES } from './project-constants.ts';
23+
import { readPackages } from './lib/package-graph.ts';
24+
import { tarballPath } from './lib/npm-tarball.ts';
25+
26+
const repoRoot = resolve(import.meta.dirname!, '..');
27+
// Generous ceiling for the real SSG build; a hung packed adapter must fail
28+
// the tool instead of stalling CI forever (same contract as
29+
// consumer-packaged-starter.ts).
30+
const BUILD_TIMEOUT_MS = 10 * 60_000;
31+
32+
async function run(
33+
command: string,
34+
args: string[],
35+
cwd: string,
36+
timeoutMs?: number,
37+
): Promise<{ success: boolean; output: string }> {
38+
const controller = new AbortController();
39+
let timedOut = false;
40+
const timeoutId = timeoutMs === undefined ? undefined : setTimeout(() => {
41+
timedOut = true;
42+
controller.abort();
43+
}, timeoutMs);
44+
try {
45+
const result = await new Deno.Command(command, {
46+
args,
47+
cwd,
48+
stdout: 'piped',
49+
stderr: 'piped',
50+
...(timeoutMs === undefined ? {} : { signal: controller.signal }),
51+
}).output();
52+
const decoder = new TextDecoder();
53+
const output = decoder.decode(result.stdout) + decoder.decode(result.stderr);
54+
if (timedOut) {
55+
return {
56+
success: false,
57+
output: `Timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}\n${output}`,
58+
};
59+
}
60+
return { success: result.success, output };
61+
} finally {
62+
clearTimeout(timeoutId);
63+
}
64+
}
65+
66+
function assertIncludes(haystack: string, needle: string, label: string): void {
67+
if (!haystack.includes(needle)) {
68+
throw new Error(`Packed consumer assertion failed (${label}): missing ${needle}`);
69+
}
70+
}
71+
72+
const CONSUMER_DENO_JSON = {
73+
imports: {
74+
'@openelement/app': `npm:@openelement/app@${PACKAGE_VERSION}`,
75+
'@openelement/adapter-vite': `npm:@openelement/adapter-vite@${PACKAGE_VERSION}`,
76+
'@openelement/element': `npm:@openelement/element@${PACKAGE_VERSION}`,
77+
'@openelement/element/jsx-runtime': `npm:@openelement/element@${PACKAGE_VERSION}/jsx-runtime`,
78+
'@openelement/element/jsx-dev-runtime':
79+
`npm:@openelement/element@${PACKAGE_VERSION}/jsx-dev-runtime`,
80+
'@openelement/ui': `npm:@openelement/ui@${PACKAGE_VERSION}`,
81+
'@openelement/ui/open-theme-toggle': `npm:@openelement/ui@${PACKAGE_VERSION}/open-theme-toggle`,
82+
'hono': 'npm:hono@^4.12',
83+
'vite': 'npm:vite@8.0.16',
84+
},
85+
nodeModulesDir: 'manual',
86+
minimumDependencyAge: 0,
87+
tasks: {
88+
build:
89+
`deno run --config deno.json -A npm:@openelement/adapter-vite@${PACKAGE_VERSION}/cli/build`,
90+
},
91+
compilerOptions: {
92+
lib: ['ES2022', 'DOM', 'DOM.Iterable'],
93+
jsx: 'react-jsx',
94+
jsxImportSource: '@openelement/element',
95+
},
96+
};
97+
98+
const CONSUMER_VITE_CONFIG = `import { openElement } from '@openelement/adapter-vite';
99+
import { defineConfig } from 'vite';
100+
101+
export default defineConfig({
102+
base: '/',
103+
esbuild: {
104+
jsx: 'automatic',
105+
jsxImportSource: '@openelement/element',
106+
},
107+
plugins: [
108+
...openElement({
109+
routesDir: 'app/routes',
110+
appShell: false,
111+
// The documented packed-artifact admission path under test (#1301).
112+
packageIslands: ['@openelement/ui'],
113+
ssr: {
114+
noExternal: ['@openelement/ui'],
115+
},
116+
html: {
117+
title: 'packed ui consumer',
118+
},
119+
}),
120+
],
121+
});
122+
`;
123+
124+
const CONSUMER_ROUTE = `import { definePage } from '@openelement/app';
125+
import HomePage from '../components/page-home.tsx';
126+
127+
export default definePage(HomePage, {
128+
head: { title: 'packed ui consumer — home' },
129+
});
130+
`;
131+
132+
const CONSUMER_PAGE = `import { element, OpenElement } from '@openelement/element';
133+
import '@openelement/ui/open-theme-toggle';
134+
135+
@element('index-page', { root: 'shadow-open' })
136+
export default class HomePage extends OpenElement {
137+
render() {
138+
return (
139+
<main>
140+
<h1 id='home-marker'>packed ui consumer home</h1>
141+
<open-theme-toggle theme='light'></open-theme-toggle>
142+
</main>
143+
);
144+
}
145+
}
146+
`;
147+
148+
const tmp = await Deno.makeTempDir({ prefix: 'openelement-packaged-ui-' });
149+
try {
150+
const workspacePackages = await readPackages();
151+
const tarballs = RETAINED_PACKAGE_NAMES.map((name) => {
152+
const pkg = workspacePackages.find((candidate) => candidate.name === name);
153+
if (!pkg) throw new Error(`Retained package missing from workspace graph: ${name}`);
154+
return { name, path: join(repoRoot, tarballPath(pkg)) };
155+
});
156+
for (const tarball of tarballs) {
157+
if (!existsSync(tarball.path)) {
158+
throw new Error(
159+
`Missing packed release artifact: ${tarball.path} (run \`deno task pack:dry-run\` first)`,
160+
);
161+
}
162+
}
163+
164+
// @jsr/* packages are served by JSR's npm compatibility layer (see
165+
// consumer-packaged-starter.ts, #886).
166+
Deno.writeTextFileSync(join(tmp, '.npmrc'), '@jsr:registry=https://npm.jsr.io\n');
167+
168+
// An explicit package.json with file: deps keeps npm from walking ancestor
169+
// directories and from pruning the external deps on tarball re-install.
170+
const dependencies: Record<string, string> = {
171+
'vite': '8.0.16',
172+
'hono': '4.12.0',
173+
};
174+
for (const tarball of tarballs) dependencies[tarball.name] = `file:${tarball.path}`;
175+
Deno.writeTextFileSync(
176+
join(tmp, 'package.json'),
177+
formatJson({
178+
name: 'openelement-packed-ui-consumer',
179+
private: true,
180+
type: 'module',
181+
dependencies,
182+
}),
183+
);
184+
185+
const install = await run(
186+
'npm',
187+
['install', '--ignore-scripts', '--no-audit', '--no-fund'],
188+
tmp,
189+
);
190+
if (!install.success) throw new Error(`Packed package installation failed:\n${install.output}`);
191+
192+
// The packed tarballs only cover @openelement/*; vite/hono resolve from the
193+
// registry. npm lays the tarball contents into node_modules directly, so no
194+
// repo node_modules scavenging happens here — the consumer is hermetic.
195+
196+
Deno.writeTextFileSync(join(tmp, 'deno.json'), formatJson(CONSUMER_DENO_JSON));
197+
Deno.writeTextFileSync(join(tmp, 'vite.config.ts'), CONSUMER_VITE_CONFIG);
198+
Deno.mkdirSync(join(tmp, 'app', 'routes'), { recursive: true });
199+
Deno.mkdirSync(join(tmp, 'app', 'components'), { recursive: true });
200+
Deno.writeTextFileSync(join(tmp, 'app', 'routes', 'index.tsx'), CONSUMER_ROUTE);
201+
Deno.writeTextFileSync(join(tmp, 'app', 'components', 'page-home.tsx'), CONSUMER_PAGE);
202+
203+
const build = await run(Deno.execPath(), ['task', 'build'], tmp, BUILD_TIMEOUT_MS);
204+
if (!build.success) {
205+
throw new Error(`Packed ui consumer SSG build failed:\n${build.output}`);
206+
}
207+
208+
// A green exit alone is not enough: the prerendered page must carry the
209+
// compiled DSD for the packed island — the host tag, its declarative shadow
210+
// template, the compiled static markup and the compiled data-theme sink.
211+
const indexHtmlPath = join(tmp, 'dist', 'index.html');
212+
if (!existsSync(indexHtmlPath)) {
213+
throw new Error(`Packed ui consumer build emitted no prerendered page: ${indexHtmlPath}`);
214+
}
215+
const html = Deno.readTextFileSync(indexHtmlPath);
216+
assertIncludes(html, '<open-theme-toggle theme="light">', 'island host tag');
217+
assertIncludes(html, '<template shadowrootmode="open"', 'island DSD template');
218+
assertIncludes(html, 'class="theme-toggle"', 'compiled island markup');
219+
assertIncludes(html, 'data-theme="light"', 'compiled property sink');
220+
221+
console.log(
222+
`Packed @openelement/ui consumer qualification passed for ${PACKAGE_VERSION}: ` +
223+
'packageIslands SSR admission renders the compiled DSD from the packed artifact.',
224+
);
225+
} finally {
226+
await Deno.remove(tmp, { recursive: true }).catch(() => undefined);
227+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { assert, assertEquals, assertStringIncludes } from '@std/assert';
2+
import { join } from '@std/path';
3+
import {
4+
compilePackageElementModules,
5+
stageCompiledPackWorkspace,
6+
} from './compiled-pack-staging.ts';
7+
import type { PackageInfo } from './package-graph.ts';
8+
9+
function pkg(name: string, dir: string): PackageInfo {
10+
return {
11+
name,
12+
version: '0.0.0-test',
13+
dir,
14+
deps: [],
15+
exports: {},
16+
importKeys: new Set(),
17+
importValues: {},
18+
};
19+
}
20+
21+
const COMPILED_COMPONENT = `import { element, OpenElement, property } from '@openelement/element';
22+
23+
@element('demo-widget', { root: 'shadow-open' })
24+
export class DemoWidget extends OpenElement {
25+
@property({ reflect: false })
26+
label: string = 'demo';
27+
28+
render() {
29+
return <span class='widget'>{this.label}</span>;
30+
}
31+
}
32+
`;
33+
34+
const PLAIN_MODULE = `export const answer: number = 42;
35+
`;
36+
37+
async function makeFixturePackage(): Promise<{ dir: string; cleanup: () => Promise<void> }> {
38+
const dir = await Deno.makeTempDir({ prefix: 'compiled-pack-staging-test-' });
39+
Deno.mkdirSync(join(dir, 'src'), { recursive: true });
40+
Deno.writeTextFileSync(join(dir, 'src', 'demo-widget.tsx'), COMPILED_COMPONENT);
41+
Deno.writeTextFileSync(join(dir, 'src', 'plain.ts'), PLAIN_MODULE);
42+
Deno.writeTextFileSync(
43+
join(dir, 'deno.json'),
44+
JSON.stringify({ name: '@openelement/demo', version: '0.0.0-test', exports: './src/mod.ts' }),
45+
);
46+
Deno.writeTextFileSync(join(dir, 'stray.tgz'), 'not a real tarball');
47+
return { dir, cleanup: () => Deno.remove(dir, { recursive: true }).catch(() => undefined) };
48+
}
49+
50+
Deno.test('compilePackageElementModules compiles opted-in .tsx and passes plain modules through', () => {
51+
const outputs = compilePackageElementModules('packages/ui');
52+
assert(outputs.length > 0, 'packages/ui ships compiled-element modules');
53+
for (const output of outputs) {
54+
assertStringIncludes(output.code, 'static __partProgram = __partProgram;');
55+
assert(!output.code.includes('@element('), 'decorator application must be erased');
56+
assert(!output.code.includes('@property('), 'property intrinsic must be erased');
57+
assert(
58+
!output.code.includes('sourceMappingURL=data:application/json'),
59+
'standalone inline map must be stripped for the packed artifact',
60+
);
61+
}
62+
});
63+
64+
Deno.test('compilePackageElementModules returns [] for packages without compiled elements', () => {
65+
assertEquals(compilePackageElementModules('packages/create'), []);
66+
});
67+
68+
Deno.test('stageCompiledPackWorkspace stages compiler output and relaxed member options', async () => {
69+
const fixture = await makeFixturePackage();
70+
try {
71+
const target = pkg('@openelement/demo', fixture.dir);
72+
const compiled = compilePackageElementModules(fixture.dir);
73+
assertEquals(compiled.length, 1);
74+
75+
const staged = await stageCompiledPackWorkspace(target, [target], {
76+
imports: { '@openelement/element': 'npm:@openelement/element@0.0.0-test' },
77+
compilerOptions: { strict: true },
78+
}, compiled);
79+
try {
80+
const stagedComponent = Deno.readTextFileSync(join(staged.packDir, 'src', 'demo-widget.tsx'));
81+
assertStringIncludes(stagedComponent, 'static __partProgram = __partProgram;');
82+
assert(!stagedComponent.includes('@element('));
83+
84+
// Non-component files pass through untouched.
85+
assertEquals(Deno.readTextFileSync(join(staged.packDir, 'src', 'plain.ts')), PLAIN_MODULE);
86+
87+
// Tarball artifacts and publish inputs never leak into staging.
88+
let strayPresent = false;
89+
try {
90+
Deno.statSync(join(staged.packDir, 'stray.tgz'));
91+
strayPresent = true;
92+
} catch { /* expected absent */ }
93+
assert(!strayPresent, 'stale .tgz must not be staged');
94+
95+
const memberConfig = JSON.parse(
96+
Deno.readTextFileSync(join(staged.packDir, 'deno.json')),
97+
) as { compilerOptions: Record<string, unknown> };
98+
assertEquals(memberConfig.compilerOptions.noImplicitOverride, false);
99+
assertEquals(memberConfig.compilerOptions.noImplicitAny, false);
100+
101+
const rootConfig = JSON.parse(
102+
Deno.readTextFileSync(join(staged.packDir, '..', 'deno.json')),
103+
) as { workspace: string[]; imports: Record<string, string> };
104+
assertEquals(rootConfig.workspace, [`./${fixture.dir.split('/').pop()}`]);
105+
assertEquals(rootConfig.imports, {
106+
'@openelement/element': 'npm:@openelement/element@0.0.0-test',
107+
});
108+
} finally {
109+
await staged.cleanup();
110+
}
111+
} finally {
112+
await fixture.cleanup();
113+
}
114+
});

0 commit comments

Comments
 (0)