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
6 changes: 3 additions & 3 deletions src/compiler/types/generate-app-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ const generateComponentTypesFile = (
const hasAnyRequiredProps = modules.some((m) => m.requiredProps);
if (hasAnyRequiredProps) {
c.push(
` type OneOf<K extends string, T> = { [P in K]: T } | { [P in \`attr:\${K}\`]: T } | { [P in \`prop:\${K}\`]: T };`,
` type OneOf<K extends string, PropT, AttrT = PropT> = { [P in K]: PropT } & { [P in \`attr:\${K}\` | \`prop:\${K}\`]?: never } | { [P in \`attr:\${K}\`]: AttrT } & { [P in K | \`prop:\${K}\`]?: never } | { [P in \`prop:\${K}\`]: PropT } & { [P in K | \`attr:\${K}\`]?: never };`,
);
c.push(``);
}
Expand Down Expand Up @@ -207,8 +207,8 @@ const generateComponentTypesFile = (
// Generate OneOf unions for each required prop
const requiredUnions = m.requiredProps
.map((prop) => {
// Get the property type from the component interface
return `OneOf<"${prop.name}", ${m.tagNameAsPascal}["${prop.name}"]>`;
// Get both the property type and attribute type
return `OneOf<"${prop.name}", ${m.tagNameAsPascal}["${prop.name}"], ${m.tagNameAsPascal}Attributes["${prop.name}"]>`;
})
.join(' & ');

Expand Down
17 changes: 16 additions & 1 deletion src/compiler/types/generate-component-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,25 @@ export const generateComponentTypes = (
// Props without attributes don't need attr: or prop: prefixes - they're already property-only
const propsWithAttributes = cmp.properties.filter((prop) => prop.attribute !== undefined);
const hasExplicitAttributes = propsWithAttributes.length > 0;
const requiredProps = propsWithAttributes.filter((prop) => prop.required);
const requiredProps = propsWithAttributes.filter((prop) => {
// Exclude internal props when generating public types (areTypesInternal === false)
// Internal props are stripped from the JSX interface during distribution builds,
// so they shouldn't be included in the OneOf requirement
if (!areTypesInternal && prop.internal) {
return false;
}
return prop.required;
});
const hasRequiredProps = requiredProps.length > 0;

const explicitAttributes = propsWithAttributes
.filter((prop) => {
// Exclude internal props when generating public types (areTypesInternal === false)
if (!areTypesInternal && prop.internal) {
return false;
}
return true;
})
.map((prop) => {
const propMeta = cmp.properties.find((p) => p.name === prop.name);
const simpleType = propMeta?.type?.trim();
Expand Down
12 changes: 0 additions & 12 deletions src/declarations/stencil-public-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1620,12 +1620,6 @@ export namespace JSXBase {
results?: number;
security?: string;
unselectable?: boolean;

// Explicit attribute/property prefix support
// attr:* forces setting as an HTML attribute via setAttribute
// prop:* forces setting as a DOM property via direct assignment
[key: `attr:${string}`]: string;
[key: `prop:${string}`]: any;
}

export interface SVGAttributes<T = SVGElement> extends DOMAttributes<T> {
Expand Down Expand Up @@ -1902,12 +1896,6 @@ export namespace JSXBase {
yChannelSelector?: string;
z?: number | string;
zoomAndPan?: string;

// Explicit attribute/property prefix support
// attr:* forces setting as an HTML attribute via setAttribute
// prop:* forces setting as a DOM property via direct assignment
[key: `attr:${string}`]: string;
[key: `prop:${string}`]: any;
}

/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/ToggleEvent) */
Expand Down
69 changes: 69 additions & 0 deletions src/runtime/test/attr-prop-prefix.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,75 @@ describe('attr: and prop: prefix', () => {
await waitForChanges();
expect(div.getAttribute('some-label')).toBe('updated');
});

it('should use the correct attribute name for camelCase properties on Stencil components', async () => {
@Component({ tag: 'cmp-child' })
class CmpChild {
@Prop() overlayIndex: number;
@Prop({ attribute: 'custom-attr-name' }) customAttr: string;
render() {
return (
<div>
overlayIndex: {this.overlayIndex}, customAttr: {this.customAttr}
</div>
);
}
}

@Component({ tag: 'cmp-parent' })
class CmpParent {
render() {
return (
<div>
<cmp-child attr:overlayIndex={42} attr:customAttr="test" />
</div>
);
}
}

const { root } = await newSpecPage({
components: [CmpParent, CmpChild],
html: `<cmp-parent></cmp-parent>`,
});

const child = root.querySelector('cmp-child');
// Should use kebab-case attribute name from metadata
expect(child.getAttribute('overlay-index')).toBe('42');
expect(child.overlayIndex).toBe(42);

// Should use custom attribute name from @Prop decorator
expect(child.getAttribute('custom-attr-name')).toBe('test');
expect(child.customAttr).toBe('test');

// Should not set incorrect camelCase attribute names
expect(child.hasAttribute('overlayIndex')).toBe(false);
expect(child.hasAttribute('customAttr')).toBe(false);
});

it('should convert camelCase to kebab-case for non-Stencil elements', async () => {
@Component({ tag: 'cmp-a' })
class CmpA {
render() {
return <div attr:dataTestId="test-123" attr:ariaLabel="Test Label" attr:customAttribute="value" />;
}
}

const { root } = await newSpecPage({
components: [CmpA],
html: `<cmp-a></cmp-a>`,
});

const div = root.querySelector('div');
// Should convert camelCase to kebab-case
expect(div.getAttribute('data-test-id')).toBe('test-123');
expect(div.getAttribute('aria-label')).toBe('Test Label');
expect(div.getAttribute('custom-attribute')).toBe('value');

// Should not set camelCase versions
expect(div.hasAttribute('dataTestId')).toBe(false);
expect(div.hasAttribute('ariaLabel')).toBe(false);
expect(div.hasAttribute('customAttribute')).toBe(false);
});
});

describe('prop: prefix', () => {
Expand Down
20 changes: 18 additions & 2 deletions src/runtime/vdom/set-accessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import { BUILD } from '@app-data';
import { isMemberInElement, plt, win } from '@platform';
import { getHostRef, isMemberInElement, plt, win } from '@platform';
import { isComplexType } from '../../utils/helpers';

import type * as d from '../../declarations';
Expand Down Expand Up @@ -145,7 +145,23 @@ export const setAccessor = (
}
} else if (BUILD.vdomPropOrAttr && memberName[0] === 'a' && memberName.startsWith('attr:')) {
// Explicit attr: prefix — always set as attribute, bypass heuristic
const attrName = memberName.slice(5);
const propName = memberName.slice(5);
// Look up the actual attribute name from component metadata
// Component metadata stores [flags, attributeName] for each member
let attrName: string | undefined;
if (BUILD.member) {
const hostRef = getHostRef(elm);
if (hostRef && hostRef.$cmpMeta$ && hostRef.$cmpMeta$.$members$) {
const memberMeta = hostRef.$cmpMeta$.$members$[propName];
if (memberMeta && memberMeta[1]) {
attrName = memberMeta[1];
}
}
}
// Fallback: convert camelCase to kebab-case if no metadata found
if (!attrName) {
attrName = propName.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
}
if (newValue == null || newValue === false) {
// null or undefined or false (and no value) - remove attribute
if (newValue !== false || elm.getAttribute(attrName) === '') {
Expand Down
12 changes: 6 additions & 6 deletions test/wdio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
"version": "0.0.0",
"scripts": {
"build": "run-s build.no-external-runtime build.test-sibling build.main build.global-script build.prerender build.invisible-prehydration build.es2022",
"build.main": "node ../../bin/stencil build --debug --es5 && cp src/components.d.ts dist/components.d.ts",
"build.es2022": "node ../../bin/stencil build --debug --config stencil.config-es2022.ts",
"build.global-script": "node ../../bin/stencil build --debug --es5 --config global-script.stencil.config.ts",
"build.main": "node ../../bin/stencil build --es5 && cp src/components.d.ts dist/components.d.ts",
"build.es2022": "node ../../bin/stencil build --config stencil.config-es2022.ts",
"build.global-script": "node ../../bin/stencil build --es5 --config global-script.stencil.config.ts",
"build.test-sibling": "cd test-sibling && npm run build",
"build.prerender": "node ../../bin/stencil build --config prerender.stencil.config.ts --prerender --debug && node ./test-prerender/prerender.js && node ./test-prerender/no-script-build.js",
"build.invisible-prehydration": "node ../../bin/stencil build --debug --es5 --config invisible-prehydration.stencil.config.ts",
"build.no-external-runtime": "node ../../bin/stencil build --debug --es5 --config no-external-runtime.stencil.config.ts",
"build.prerender": "node ../../bin/stencil build --config prerender.stencil.config.ts --prerender && node ./test-prerender/prerender.js && node ./test-prerender/no-script-build.js",
"build.invisible-prehydration": "node ../../bin/stencil build --es5 --config invisible-prehydration.stencil.config.ts",
"build.no-external-runtime": "node ../../bin/stencil build --es5 --config no-external-runtime.stencil.config.ts",
"test": "run-s build wdio end-to-end",
"wdio": "wdio run ./wdio.conf.ts",
"end-to-end": "node ./test-end-to-end-import.mjs"
Expand Down
8 changes: 4 additions & 4 deletions test/wdio/prefix-attr/cmp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ describe('prefix-attr', () => {
await expect(nested).toHaveAttribute('message', 'Hello');
await expect(nested).toHaveAttribute('count', '42');
await expect(nested).toHaveAttribute('enabled');
await expect(nested).toHaveAttribute('nullValue', 'not-null');
await expect(nested).toHaveAttribute('undefinedValue', 'defined');
await expect(nested).toHaveAttribute('null-value', 'not-null');
await expect(nested).toHaveAttribute('undefined-value', 'defined');
});

it('should update nested component when parent state changes', async () => {
Expand All @@ -37,10 +37,10 @@ describe('prefix-attr', () => {

const setNullBtn = await $('button=Set Null to String');
await setNullBtn.click();
await expect(nested).not.toHaveAttribute('nullValue');
await expect(nested).not.toHaveAttribute('null-value');

const setUndefinedBtn = await $('button=Set Undefined to String');
await setUndefinedBtn.click();
await expect(nested).not.toHaveAttribute('undefinedValue');
await expect(nested).not.toHaveAttribute('undefined-value');
});
});
1 change: 1 addition & 0 deletions test/wdio/stencil.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const config: Config = {
plugins: [
sass({
quietDeps: true,
silenceDeprecations: ['import'],
}),
],
buildDist: true,
Expand Down
Loading