Skip to content

Commit dc3ee19

Browse files
SisyphusZhengDevBot
andauthored
fix(v0.44): bind page-route SSR tags to the compiled Part Program; gate workspace qualification in CI (#1276) (#1277)
Touches ADR-0122 frozen path entry-codegen.ts; amendment authority ADR-0151. B1.3 qualification blocker B1.3-F1: entry codegen registered and rendered definePage routes under the path-derived fileToTagName tag while the page element's compiled Part Program carries its @element(...) tag; the 0.44 serializer fails closed on the mismatch (OE_PROGRAM_MISSING, phase ssr), producing request-time HTTP 500 on /workspace-records, /magic-link and /reset-password under both Node 24 serve.mjs and Wrangler workerd. Repair per the thinker ruling: the route->program binding follows the element's declared tag — the compiled program is the one canonical source. The generated entry now resolves every page route's SSR tag at module evaluation via __resolvePageTag(routeModule, fallbackTag), which reads the route module's default-exported compiled class's __partProgram.tag and keeps the path-derived tag only as the fallback for classes without a compiled program (renderDsd still fails closed on those, exactly as before). One canonical binding covers SSR registration, the page and 404 handlers, and the SSG routeInfo; .mdx pages are unaffected because their compiled tag is path-derived by construction. The route file-convention grammar (route-scanner.ts) keeps owning the ROUTE; it never owned the element's identity, and static tag extraction there would need the cross-file module analysis ADR-0148 excludes from the semantic core. The B1.3 qualification also exposed a tooling drift it had masked: the v0.44 fixture migration (c16b8dd) moved pagination from an <a id="next-page" href> anchor to a GET form whose hidden inputs ride compiled property Parts (dynamic intrinsic attributes are outside the SSR part schema in grammar v1), so tools/qualify-workspace-runtime.ts now reproduces the browser's GET form submission instead of scraping an anchor. Regression gate: fullstack:workspace-qualification is a required autoflow-ci job, and the PR full-CI evidence set (tools/autoflow/loop-evidence.ts REQUIRED_PR_CI_JOBS) is extended to match — the provenance verifier fails closed on unregistered run jobs, so the workflow change and the allow-list must move together. entry-codegen.ts is an ADR-0122 frozen path; this repair is authorized under the ADR-0151 Beta.1 framework-qualification boundary and changes no public API (package surface and public interface snapshot unchanged). Part of #1276. Part of #1224, #1150, #1155. Co-authored-by: DevBot <devbot@openelement.dev>
1 parent 73a7900 commit dc3ee19

17 files changed

Lines changed: 282 additions & 41 deletions

.github/workflows/autoflow-ci.yml

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,29 @@ jobs:
112112
grep -q 'requires a runtime with WHATWG URLPattern' serve-error.log
113113
fi
114114
115+
# #1276 (B1.3-F1): the definePage route SSR tag-mismatch defect shipped
116+
# because the workspace runtime qualification was not CI-gated. Run the full
117+
# qualification leg (starter build -> Node 24 standalone + Wrangler workerd,
118+
# byte-identical HTML across both) as a required CI job so this defect class
119+
# cannot regress silently. Needs real Node 24 (the serve.mjs runtime floor,
120+
# #969) — the autoflow-ci job itself has no Node setup.
121+
workspace-qualification:
122+
runs-on: ubuntu-latest
123+
timeout-minutes: 20
124+
steps:
125+
# v7.0.1
126+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
127+
with:
128+
# #1156 R11: same exact-SHA expression as every required job.
129+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
130+
- uses: ./.github/actions/setup-deno-workspace
131+
# v7.0.0
132+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
133+
with:
134+
node-version: '24'
135+
- name: Workspace runtime qualification (Node 24 + workerd)
136+
run: deno task fullstack:workspace-qualification
137+
115138
# #1156 (ADR-0146): one deterministic exact-SHA PR full-CI evidence artifact.
116139
# This job runs only for pull requests and only after every required
117140
# full-matrix job succeeded (default needs gating — no `if: always()`), so a
@@ -122,7 +145,7 @@ jobs:
122145
pr-full-ci-evidence:
123146
name: pr-full-ci-evidence
124147
if: github.event_name == 'pull_request'
125-
needs: [dependency-review, autoflow-ci, node-serve-smoke]
148+
needs: [dependency-review, autoflow-ci, node-serve-smoke, workspace-qualification]
126149
runs-on: ubuntu-latest
127150
timeout-minutes: 5
128151
permissions:

packages/adapter-vite/__tests__/entry-descriptor.test.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ Deno.test('buildEntryDescriptor: plain element route keeps its tagName export (#
445445
assertEquals(desc.pageRoutes[0].defaultTagName, 'index-page');
446446
});
447447

448-
Deno.test('renderEntry: definePage route registers and renders under the fallback tag (#960)', () => {
448+
Deno.test('renderEntry: definePage route binds its tag through the compiled program, never the tagName export (#960, #1276)', () => {
449449
const routes: RouteEntry[] = [
450450
{
451451
path: '/',
@@ -458,21 +458,29 @@ Deno.test('renderEntry: definePage route registers and renders under the fallbac
458458
];
459459
const code = renderEntry(buildEntryDescriptor(routes));
460460

461-
// Registration and the page handler's jsx root both use the fallback tag —
462-
// the exported content-element tag never appears in the registration call,
463-
// so a module-self-registered content element can no longer shadow the
464-
// definePage render (the issue's original failure mode).
465-
assertStringIncludes(code, '__registerSsrComponent("index-page"');
461+
// Registration and the page handler resolve the tag from the route module's
462+
// compiled Part Program at entry evaluation (#1276); the path-derived
463+
// fallback tag is the resolver's fallback argument only, and the exported
464+
// content-element tag never appears — a module-self-registered content
465+
// element can no longer shadow the definePage render (the #960 failure
466+
// mode).
467+
assertStringIncludes(
468+
code,
469+
'__registerSsrComponent(__resolvePageTag($pageIndex, "index-page")',
470+
);
466471
assertEquals(code.includes('__registerSsrComponent("home-page"'), false);
467-
assertStringIncludes(code, 'let __tag = "index-page"');
472+
assertEquals(code.includes('__resolvePageTag($pageIndex, "home-page"'), false);
473+
assertStringIncludes(code, 'let __tag = __resolvePageTag($pageIndex, "index-page")');
468474
});
469475

470-
Deno.test('renderEntry: plain element route still registers under its tagName export', () => {
476+
Deno.test('renderEntry: plain element route binds its tag through the compiled program too (#1276)', () => {
471477
const routes: RouteEntry[] = [
472478
{ path: '/', filePath: 'index.tsx', type: 'page', varName: 'pageIndex', tagName: 'home-page' },
473479
];
474480
const code = renderEntry(buildEntryDescriptor(routes));
475481

476-
assertStringIncludes(code, '__registerSsrComponent("home-page"');
477-
assertStringIncludes(code, 'let __tag = "home-page"');
482+
// One canonical binding for every page route: the scanner's tagName export
483+
// is the fallback argument; the compiled program tag wins at evaluation.
484+
assertStringIncludes(code, '__registerSsrComponent(__resolvePageTag($pageIndex, "home-page")');
485+
assertStringIncludes(code, 'let __tag = __resolvePageTag($pageIndex, "home-page")');
478486
});

packages/adapter-vite/__tests__/entry-render-ssg.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ const $boom_route = {
5151
default: class BoomPage {},
5252
};
5353
function __pageDefinition(m) { return m?.default?.openElementPage || {}; }
54+
function __resolvePageTag(routeModule, fallback) {
55+
const program = routeModule && routeModule.default && routeModule.default.__partProgram;
56+
return program && typeof program.tag === "string" && program.tag.includes("-") ? program.tag : fallback;
57+
}
5458
function __routeMeta() { return {}; }
5559
function __isOpenElementRedirect(e) { return e && e.__openRedirect === true; }
5660
function __isOpenElementNotFound(e) { return e && e.__openNotFound === true; }
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* #1276 (B1.3-F1): definePage route SSR tag-mismatch repair.
3+
*
4+
* The route→program tag binding follows the element's declared tag — the
5+
* compiled Part Program is the one canonical source. The route FILE name
6+
* derives the ROUTE (path/fallback wiring), never the element's identity:
7+
* entry codegen resolves the SSR registration/render tag from the route
8+
* module's compiled program (`default.__partProgram.tag`) at generated-entry
9+
* evaluation time, with the path-derived tag kept only as the fallback for
10+
* classes that carry no compiled program (renderDsd still fails closed on
11+
* those, exactly as before).
12+
*
13+
* Pre-fix, definePage routes registered/rendered under the bare path-derived
14+
* tag, so a page element whose @element tag differed from the file-derived
15+
* tag (e.g. routes/workspace-records.tsx -> @element('workspace-records-page'))
16+
* failed closed at REQUEST time: renderDsd tag "workspace-records" does not
17+
* match the compiled program tag "workspace-records-page" (OE_PROGRAM_MISSING)
18+
* → HTTP 500. Proven by `deno task fullstack:workspace-qualification`.
19+
*/
20+
import { assertEquals, assertStringIncludes } from '@std/assert';
21+
import { buildEntryDescriptor, renderEntry } from '../src/internal/ssg/index.ts';
22+
import { renderRuntimeHelpers } from '../src/internal/ssg/entry-render-runtime.ts';
23+
import type { RouteEntry } from '../src/internal/protocol/framework.ts';
24+
25+
const definePageRoutes: RouteEntry[] = [
26+
{
27+
path: '/workspace-records',
28+
filePath: 'workspace-records.tsx',
29+
type: 'page',
30+
varName: 'pageWorkspaceRecords',
31+
definePage: true,
32+
},
33+
];
34+
35+
const RESOLUTION_EXPR = '__resolvePageTag($pageWorkspaceRecords, "workspace-records")';
36+
37+
Deno.test('renderEntry: definePage route registers through the compiled-program tag resolution (#1276)', () => {
38+
const code = renderEntry(buildEntryDescriptor(definePageRoutes));
39+
40+
// Registration resolves the tag from the route module's compiled program;
41+
// the path-derived tag survives only as the resolver's fallback argument.
42+
assertStringIncludes(
43+
code,
44+
`__registerSsrComponent(${RESOLUTION_EXPR}, $pageWorkspaceRecords.default)`,
45+
);
46+
});
47+
48+
Deno.test('renderEntry: definePage route handler renders through the compiled-program tag resolution (#1276)', () => {
49+
const code = renderEntry(buildEntryDescriptor(definePageRoutes));
50+
51+
assertStringIncludes(code, `let __tag = ${RESOLUTION_EXPR}`);
52+
});
53+
54+
Deno.test('renderEntry: SSG routeInfo resolves the tag from the compiled program (#1276)', () => {
55+
const code = renderEntry(buildEntryDescriptor(definePageRoutes, { ssg: true }));
56+
57+
assertStringIncludes(code, `tagName: ${RESOLUTION_EXPR},`);
58+
});
59+
60+
Deno.test('renderEntry: styled 404 route renders through the compiled-program tag resolution (#1276)', () => {
61+
const routes: RouteEntry[] = [
62+
...definePageRoutes,
63+
{
64+
path: '/404',
65+
filePath: '404.tsx',
66+
type: 'page',
67+
varName: 'pageNotFound',
68+
definePage: true,
69+
},
70+
];
71+
const code = renderEntry(buildEntryDescriptor(routes));
72+
73+
assertStringIncludes(code, 'let __tag = __resolvePageTag($pageNotFound, "el-404");');
74+
});
75+
76+
interface ResolvePageTagHarness {
77+
resolvePageTag(routeModule: unknown, fallback: string): string;
78+
}
79+
80+
async function loadHarness(): Promise<ResolvePageTagHarness> {
81+
const helpers = renderRuntimeHelpers({ default: false, layouts: {} }, []);
82+
const harness = `
83+
const customElements = { get() { return undefined; } };
84+
const escapeHtml = (value) => String(value);
85+
const __locales = ["en"];
86+
const __getDefaultLocale = () => "en";
87+
const __navSections = [];
88+
const __headerNav = [];
89+
function renderDsd() { return { html: "" }; }
90+
${helpers}
91+
export function resolvePageTag(routeModule, fallback) { return __resolvePageTag(routeModule, fallback); }
92+
`;
93+
const mod = await import('data:text/javascript;charset=utf-8,' + encodeURIComponent(harness));
94+
return mod as ResolvePageTagHarness;
95+
}
96+
97+
Deno.test('__resolvePageTag: compiled program tag wins over the path-derived fallback (#1276)', async () => {
98+
const harness = await loadHarness();
99+
// The definePage route module default-exports the compiled page class
100+
// (definePage returns the class), whose __partProgram.tag is the @element
101+
// tag — this is the mismatch shape from the B1.3 qualification.
102+
const routeModule = { default: { __partProgram: { tag: 'workspace-records-page' } } };
103+
assertEquals(harness.resolvePageTag(routeModule, 'workspace-records'), 'workspace-records-page');
104+
});
105+
106+
Deno.test('__resolvePageTag: matching program and fallback tags resolve identically', async () => {
107+
const harness = await loadHarness();
108+
const routeModule = { default: { __partProgram: { tag: 'login' } } };
109+
assertEquals(harness.resolvePageTag(routeModule, 'login'), 'login');
110+
});
111+
112+
Deno.test('__resolvePageTag: no compiled program keeps the path-derived fallback (#1276)', async () => {
113+
const harness = await loadHarness();
114+
assertEquals(
115+
harness.resolvePageTag({ default: class {} }, 'workspace-records'),
116+
'workspace-records',
117+
);
118+
assertEquals(
119+
harness.resolvePageTag({ default: undefined }, 'workspace-records'),
120+
'workspace-records',
121+
);
122+
assertEquals(harness.resolvePageTag(undefined, 'workspace-records'), 'workspace-records');
123+
});
124+
125+
Deno.test('__resolvePageTag: malformed program tags keep the path-derived fallback (#1276)', async () => {
126+
const harness = await loadHarness();
127+
// Not a custom-element tag (no hyphen) or not a string at all: never let a
128+
// malformed program tag reach the registration/render call sites.
129+
assertEquals(
130+
harness.resolvePageTag({ default: { __partProgram: { tag: 'nohyphen' } } }, 'x-page'),
131+
'x-page',
132+
);
133+
assertEquals(
134+
harness.resolvePageTag({ default: { __partProgram: { tag: 42 } } }, 'x-page'),
135+
'x-page',
136+
);
137+
});

packages/adapter-vite/src/internal/ssg/entry-codegen.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ import { quoteGeneratedJavaScriptValue } from './codegen-literals.ts';
1414
import {
1515
documentWrapOptionsLines,
1616
pageDefinitionExpr,
17+
pageRouteTagExpr,
1718
rendererScopeMatches,
1819
routeMetaExpr,
19-
routeTagNameExpr,
2020
} from './entry-route-helpers.ts';
2121

2222
/**
@@ -336,7 +336,7 @@ export function renderRouteHandler(
336336
matchingRenderers: renderers.filter((r) => rendererScopeMatches(route.path, r.scope)),
337337
docConfig,
338338
pathLiteral: quoteGeneratedJavaScriptValue(route.path),
339-
tagNameExpr: routeTagNameExpr(route.tagName),
339+
tagNameExpr: pageRouteTagExpr(route.varName, route.tagName),
340340
pageDefExpr: pageDefinitionExpr(route.varName),
341341
routeMeta: routeMetaExpr(route.varName),
342342
routeContext: `{ path: ${quoteGeneratedJavaScriptValue(route.path)}, filePath: ${

packages/adapter-vite/src/internal/ssg/entry-descriptor.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,11 +221,13 @@ export function buildEntryDescriptor(
221221
varName: `$${r.varName}`,
222222
filePath: r.filePath,
223223
defaultTagName: fallbackTagName,
224-
// #960 (registration decoupling, Option 2): a definePage route always
225-
// registers its page class under the path-derived fallback tag, so the
226-
// definePage render always runs — its tagName export only names a
227-
// content element and never drives registration. Plain element routes
228-
// keep the exported tagName as their registration tag.
224+
// #960 (registration decoupling, Option 2) + #1276 (B1.3-F1): a
225+
// definePage route's tagName export only names a content element and
226+
// never drives registration. For every page route the generated entry
227+
// resolves the SSR tag from the route module's compiled Part Program
228+
// at evaluation time (__resolvePageTag); the tag here is the
229+
// resolver's path-derived fallback, used only when the class carries
230+
// no compiled program.
229231
tagName: r.definePage === true ? fallbackTagName : (r.tagName || fallbackTagName),
230232
importPath: `/${routesDir}/${r.filePath}`,
231233
isDynamic,

packages/adapter-vite/src/internal/ssg/entry-not-found-codegen.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { quoteGeneratedJavaScriptValue } from './codegen-literals.ts';
44
import {
55
documentWrapOptionsLines,
66
pageDefinitionExpr,
7+
pageRouteTagExpr,
78
rendererScopeMatches,
89
routeMetaExpr,
9-
routeTagNameExpr,
1010
} from './entry-route-helpers.ts';
1111

1212
interface RouteHandlerDocConfig {
@@ -30,7 +30,7 @@ export function renderNotFoundRoute(
3030
lines.push('app.notFound(async (c) => {');
3131
lines.push(` const __responseHeaders = new Headers();`);
3232
lines.push(` return __mergeChannelHeaders(await (async () => {`);
33-
lines.push(` let __tag = ${routeTagNameExpr(route.tagName)};`);
33+
lines.push(` let __tag = ${pageRouteTagExpr(route.varName, route.tagName)};`);
3434
lines.push(` let __page = ${pageDefinitionExpr(route.varName)};`);
3535
lines.push(` let __params = {};`);
3636
lines.push(` let __routeMetaValue = ${routeMetaExpr(route.varName)};`);

packages/adapter-vite/src/internal/ssg/entry-orchestrator.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import type { EntryDescriptor } from '../protocol/ssg.ts';
3232
import { validateIslandModuleSpecifier } from './entry-generators.ts';
3333
import { renderActionRoute, renderPageRoute } from './entry-codegen.ts';
3434
import { renderNotFoundRoute } from './entry-not-found-codegen.ts';
35-
import { renderImport, routeTagNameExpr } from './entry-route-helpers.ts';
35+
import { pageRouteTagExpr, renderImport } from './entry-route-helpers.ts';
3636
import { renderApiRoute, renderMiddleware } from './entry-server-codegen.ts';
3737
import { renderRuntimeHelpers } from './entry-render-runtime.ts';
3838
import { renderActionRuntime } from './entry-action-runtime.ts';
@@ -186,8 +186,11 @@ export function renderEntry(desc: EntryDescriptor): string {
186186
lines.push('};');
187187
lines.push('');
188188
// #952: entry-side registration ownership tracking. Since #960
189-
// (registration decoupling) a definePage route's page class registers
190-
// under the path-derived fallback tag; v0.44 compiled modules never
189+
// (registration decoupling) a definePage route's page class registration
190+
// is decoupled from the module's tagName export; since #1276 (B1.3-F1) the
191+
// registered tag resolves from the compiled Part Program
192+
// (__resolvePageTag), with the path-derived tag as fallback. v0.44
193+
// compiled modules never
191194
// self-register, so the entry owns every registration. The ownership
192195
// guard still covers dev re-evaluation — overwriting a fresh
193196
// self-registered class with the entry's page class would recurse when
@@ -211,7 +214,7 @@ export function renderEntry(desc: EntryDescriptor): string {
211214
lines.push('');
212215
}
213216
for (const route of desc.pageRoutes) {
214-
const tagNameExpr = routeTagNameExpr(route.varName, route.tagName);
217+
const tagNameExpr = pageRouteTagExpr(route.varName, route.tagName);
215218
lines.push(
216219
`try { __registerSsrComponent(${tagNameExpr}, ${route.varName}.default); } catch (err) { console.error('[ssg] Failed to register route custom element ${tagNameExpr}:', err); throw err; }`,
217220
);

packages/adapter-vite/src/internal/ssg/entry-render-runtime.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,28 @@ export function renderRuntimeHelpers(
7878
lines.push('}');
7979
lines.push('');
8080

81+
// #1276 (B1.3-F1): the compiled program is the one canonical source for the
82+
// route→program tag binding. A definePage route module default-exports the
83+
// compiled page class (definePage returns the class), whose
84+
// __partProgram.tag carries the @element(...) decorator tag; the route FILE
85+
// name derives the ROUTE, not the element's identity, so the bare
86+
// path-derived tag must never reach renderDsd when the program declares a
87+
// different one (request-time OE_PROGRAM_MISSING 500, B1.3 qualification).
88+
// The path-derived fallback remains for classes without a compiled program —
89+
// renderDsd fails closed on those exactly as before. Called at module
90+
// evaluation (route registration, routeInfo), so this must stay a hoisted
91+
// function declaration.
92+
lines.push('function __resolvePageTag(routeModule, fallbackTag) {');
93+
lines.push(
94+
' const program = routeModule && routeModule.default && routeModule.default.__partProgram;',
95+
);
96+
lines.push(
97+
' if (program && typeof program.tag === "string" && program.tag.includes("-")) return program.tag;',
98+
);
99+
lines.push(' return fallbackTag;');
100+
lines.push('}');
101+
lines.push('');
102+
81103
lines.push('function __localizeShellHref(href, locale, defaultLocale) {');
82104
lines.push(
83105
' if (typeof href !== "string" || locale === defaultLocale || !href.startsWith("/") || href.startsWith("//")) return href',

packages/adapter-vite/src/internal/ssg/entry-render-ssg.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import type { EntryDescriptor } from '../protocol/ssg.ts';
1010
import { quoteGeneratedJavaScriptValue } from './codegen-literals.ts';
1111
import {
1212
documentWrapOptionsLines,
13+
pageRouteTagExpr,
1314
renderMatchingRenderersFn,
14-
routeTagNameExpr,
1515
} from './entry-route-helpers.ts';
1616

1717
/**
@@ -39,7 +39,7 @@ export function renderSsgSection(desc: EntryDescriptor): string {
3939
// --- routeInfo: structured route metadata ---
4040
lines.push('export const routeInfo = [');
4141
for (const r of desc.pageRoutes) {
42-
const tagNameExpr = routeTagNameExpr(r.tagName);
42+
const tagNameExpr = pageRouteTagExpr(r.varName, r.tagName);
4343
lines.push(
4444
` { path: ${quoteGeneratedJavaScriptValue(r.path)}, filePath: ${
4545
quoteGeneratedJavaScriptValue(r.filePath)

0 commit comments

Comments
 (0)