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
36 changes: 35 additions & 1 deletion packages/element/src/internal/compiled/facade-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,22 @@ function ownDataValue(element: object, name: string): { found: boolean; value?:
return { found: true, value: descriptor.value };
}

/**
* Whether an own data property value merely restates the compiled default
* (the generated field initializer's contract, see reconcileOwnProperties)
* rather than carrying a real pre-upgrade assignment. Arrays/objects compare
* structurally because the compiler default for them is typically an inline
* literal evaluated per instance.
*/
function restatesDefault(record: CompiledPropertyMetadata, value: unknown): boolean {
const fallback = record.default;
if (value === fallback) return true;
if (Array.isArray(fallback) || (typeof fallback === 'object' && fallback !== null)) {
return JSON.stringify(value) === JSON.stringify(fallback);
}
return false;
}

/**
* Create the per-instance property state: one signal per compiled property at
* its compiled default, plus any property values set as own data properties
Expand All @@ -150,7 +166,19 @@ export function createFacadePropertyState(
if (property.computed) continue;
const own = ownDataValue(element, property.name);
if (own.found) {
pending.set(property.name, own.value);
// Generated class-field initializers restate the compiled default
// (reconcileOwnProperties' own contract). They are NOT pre-upgrade JS
// sets: capturing a restated default into pendingOwnValues would let it
// clobber the SSR attribute promotion at connect (applyPendingOwnValues
// runs after syncAttributesToSignals), so a computed field deriving from
// the property would claim against default-value-derived chrome while
// the SSR DOM was rendered from the injected props — the
// "[compiled-claim] item attribute drift" seen on every non-default
// locale page (#1318). Only a value that actually differs from the
// compiled default is a real pre-upgrade set.
if (!restatesDefault(property, own.value)) {
pending.set(property.name, own.value);
}
delete record[property.name];
}
signals[property.name] = signal<unknown>(property.default);
Expand Down Expand Up @@ -294,6 +322,12 @@ export function applyPendingOwnValues(state: FacadePropertyState): void {
const record = state.properties.find((candidate) => candidate.name === name);
if (record?.computed) continue;
const sig = record ? state.signals[record.name] : undefined;
// A pending null/undefined carries no pre-upgrade assignment intent
// (coercePropertyValue would map it to the compiled default): letting it
// through would clobber the attribute-promoted value at connect and
// desync every computed field deriving from that property (#1318 — the
// "[compiled-claim] item attribute drift" on non-default locales).
if (value === null || value === undefined) continue;
if (record && sig) sig.value = coercePropertyValue(record, value);
}
}
Expand Down
143 changes: 143 additions & 0 deletions www/__tests__/open-layout-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { assert, assertEquals, assertFalse } from '@std/assert';
import {
buildSidebarRows,
decorateHeaderNav,
filterNavSections,
footerColumn,
isExternalLayoutUrl,
isSafeLayoutUrl,
layoutChromeStrings,
localeSwitchLabel,
localeSwitchPath,
localizeLayoutPath,
Expand Down Expand Up @@ -65,3 +69,142 @@ Deno.test('open-layout navigation labels the nameless generated group as Project
assertEquals(filterNavSections(sections, '/blog').map((s) => s.section), ['History']);
assertEquals(filterNavSections(sections, '/apilist').map((s) => s.section), ['Reference']);
});

const GENERATED_LIKE_SECTIONS = [
{ section: 'Quick Start', items: [{ path: '/docs', label: 'Docs' }] },
{
section: 'Guide',
items: [
{ path: '/guide/getting-started', label: 'Getting Started' },
{ path: '/guide/api', label: 'API Routes' },
],
},
{ section: 'Core', items: [{ path: '/guide/deployment', label: 'Deployment' }] },
{ section: 'Principles', items: [{ path: '/architecture/dsd', label: 'DSD Rendering' }] },
{ section: 'Reference', items: [{ path: '/apilist', label: 'API Reference' }] },
];

Deno.test('buildSidebarRows flattens the filtered section tree into heading and link rows', () => {
const rows = buildSidebarRows(GENERATED_LIKE_SECTIONS, '/guide/api', 'en', ['en', 'zh']);
assertEquals(rows.map((row) => row.kind), [
'section',
'link',
'section',
'link',
'link',
'section',
'link',
]);
assertEquals(rows[0].heading, 'Quick Start');
assertEquals(rows[3].label, 'Getting Started');
// The active page is marked exactly once, on the exact-match link.
assertEquals(rows.filter((row) => row.current === 'page').map((row) => row.href), ['/guide/api']);
// Heading rows carry no link affordance; link rows carry no heading.
assertEquals(rows[0].href, false);
assertEquals(rows[3].heading, '');
// Row keys are unique and stable for the keyed Region.
assertEquals(new Set(rows.map((row) => row.key)).size, rows.length);
});

Deno.test('buildSidebarRows localizes link targets and matches the localized current path', () => {
const rows = buildSidebarRows(GENERATED_LIKE_SECTIONS, '/zh/guide/api', 'zh', ['en', 'zh']);
const links = rows.filter((row) => row.kind === 'link');
assert(links.every((row) => row.href !== false && row.href.startsWith('/zh/')));
assertEquals(rows.filter((row) => row.current === 'page').map((row) => row.href), [
'/zh/guide/api',
]);
});

Deno.test('buildSidebarRows filters to the active section family before flattening', () => {
const rows = buildSidebarRows(GENERATED_LIKE_SECTIONS, '/architecture/dsd', 'en', ['en', 'zh']);
assertEquals(
rows.filter((row) => row.kind === 'section').map((row) => row.heading),
['Principles', 'Reference'],
);
});

Deno.test('buildSidebarRows guards unsafe hrefs and marks external links', () => {
const rows = buildSidebarRows(
[{
section: 'Guide',
items: [
{ href: 'javascript:alert(1)', label: 'Evil' },
{ href: 'https://example.com/x', label: 'External' },
],
}],
'/guide',
'en',
['en', 'zh'],
);
const evil = rows.find((row) => row.label === 'Evil');
const external = rows.find((row) => row.label === 'External');
assertEquals(evil?.href, false);
assertEquals(external?.href, 'https://example.com/x');
assertEquals(external?.rel, 'noopener noreferrer');
});

Deno.test('decorateHeaderNav marks the current section and never external links', () => {
const links = [
{ href: '/docs', label: 'Docs' },
{ href: '/blog', label: 'Blog' },
{ href: 'https://github.com/open-element/openelement', label: 'GitHub' },
];
assertEquals(decorateHeaderNav(links, '/docs', 'en', ['en', 'zh']).map((link) => link.current), [
'page',
false,
false,
]);
// Section roots stay current on nested routes (blog posts keep Blog current).
assertEquals(
decorateHeaderNav(links, '/blog/0001-keep-hono-vite-dev-server', 'en', ['en', 'zh'])[1].current,
'page',
);
// The adapter pre-localizes header hrefs but passes the bare route path;
// current marking must still land for non-default locales.
const zhLinks = links.map((link) =>
link.href.startsWith('https:') ? link : { ...link, href: `/zh${link.href}` }
);
assertEquals(decorateHeaderNav(zhLinks, '/blog', 'zh', ['en', 'zh']).map((l) => l.current), [
false,
'page',
false,
]);
// A locale-prefixed request-time path normalizes to the same result.
assertEquals(decorateHeaderNav(zhLinks, '/zh/blog', 'zh', ['en', 'zh']).map((l) => l.current), [
false,
'page',
false,
]);
});

Deno.test('footerColumn restores the four-column link structure with localized targets', () => {
const product = footerColumn('en', ['en', 'zh'], 'product');
assertEquals(product.label, 'Product');
assertEquals(
product.links.map((link) => link.href),
[
'/guide/core-concepts',
'/architecture/design-system',
'/architecture/architecture',
'/architecture/standards-registry',
],
);
const zhProduct = footerColumn('zh', ['en', 'zh'], 'product');
assertEquals(zhProduct.label, '产品');
assert(zhProduct.links.every((link) => link.href.startsWith('/zh/')));
const company = footerColumn('en', ['en', 'zh'], 'company');
const github = company.links.find((link) => link.label === 'GitHub');
assertEquals(github?.href, 'https://github.com/open-element/openelement');
assertEquals(github?.rel, 'noopener noreferrer');
const legal = footerColumn('zh', ['en', 'zh'], 'legal');
assertEquals(legal.label, '法律');
assertEquals(legal.links.map((link) => link.label), ['MIT 许可证', '参与贡献']);
});

Deno.test('layoutChromeStrings carries the bilingual shell chrome copy', () => {
assertEquals(layoutChromeStrings('en').sidebarLabel, 'Documentation navigation');
assertEquals(layoutChromeStrings('zh').sidebarLabel, '文档导航');
assertEquals(layoutChromeStrings('en').sidebarToggle, 'Documentation');
assertEquals(layoutChromeStrings('zh').sidebarToggle, '文档');
assertEquals(typeof layoutChromeStrings('zh').footerTagline, 'string');
});
34 changes: 32 additions & 2 deletions www/__tests__/site-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,40 @@ Deno.test('open-layout is an explicitly hydrated compiled app-shell island', asy
assertStringIncludes(source, 'export default class OpenLayout extends OpenElement');
const result = compileElementProgram(source, url.pathname);
assertEquals(result.program.tag, 'open-layout');
assertEquals(result.program.regions.length, 2);
// Regions: header nav (desktop + mobile panel), sidebar rows (desktop +
// mobile disclosure panel) and the four footer link columns.
assertEquals(result.program.regions.length, 8);
// Injected shell props plus the derived chrome state as computed signal
// properties (the list-Region grammar requires `.map()` over
// `this.<property>`, so the derived sidebar rows / footer columns are
// compiled computed fields rather than render locals or accessors).
assertEquals(
result.program.metadata.properties.map((property) => property.name),
['headerNav', 'footerText', 'siteName', 'homeHref'],
[
'headerNav',
'footerText',
'siteName',
'homeHref',
'navItems',
'currentPath',
'locale',
'locales',
'home',
'headerNavItems',
'sidebarLabel',
'sidebarToggle',
'sidebarRows',
'sidebarHidden',
'footerTagline',
'footerProductLabel',
'footerProductLinks',
'footerResourcesLabel',
'footerResourcesLinks',
'footerCompanyLabel',
'footerCompanyLinks',
'footerLegalLabel',
'footerLegalLinks',
],
);
assertEquals(
result.program.metadata.properties.find((property) => property.name === 'headerNav')?.attribute,
Expand Down
Loading
Loading