Skip to content

Commit e579965

Browse files
committed
feat: add support for ElementInternals in synthetic shadow
1 parent a853060 commit e579965

File tree

12 files changed

+207
-100
lines changed

12 files changed

+207
-100
lines changed

packages/@lwc/engine-core/src/framework/base-lightning-element.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -506,11 +506,26 @@ function warnIfInvokedDuringConstruction(vm: VM, methodOrPropName: string) {
506506
);
507507
}
508508

509+
const internals = attachInternals(elm);
509510
if (vm.shadowMode === ShadowMode.Synthetic) {
510-
throw new Error('attachInternals API is not supported in synthetic shadow.');
511+
const handler = {
512+
get(target: ElementInternals, prop: keyof ElementInternals) {
513+
if (prop === 'shadowRoot') {
514+
return vm.shadowRoot;
515+
}
516+
const value = Reflect.get(target, prop);
517+
if (typeof value === 'function') {
518+
return value.bind(target);
519+
}
520+
return value;
521+
},
522+
set(target: ElementInternals, prop: keyof ElementInternals, value: any) {
523+
return Reflect.set(target, prop, value);
524+
},
525+
};
526+
return new Proxy(internals, handler);
511527
}
512-
513-
return attachInternals(elm);
528+
return internals;
514529
},
515530

516531
get isConnected(): boolean {

packages/@lwc/engine-core/src/framework/vm.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -972,14 +972,6 @@ export function forceRehydration(vm: VM) {
972972
}
973973

974974
export function runFormAssociatedCustomElementCallback(vm: VM, faceCb: () => void, args?: any[]) {
975-
const { renderMode, shadowMode } = vm;
976-
977-
if (shadowMode === ShadowMode.Synthetic && renderMode !== RenderMode.Light) {
978-
throw new Error(
979-
'Form associated lifecycle methods are not available in synthetic shadow. Please use native shadow or light DOM.'
980-
);
981-
}
982-
983975
invokeComponentCallback(vm, faceCb, args);
984976
}
985977

Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<template></template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { LightningElement, api } from 'lwc';
2+
3+
export default class extends LightningElement {
4+
internals;
5+
6+
connectedCallback() {
7+
this.internals = this.attachInternals();
8+
}
9+
10+
@api
11+
callAttachInternals() {
12+
this.internals = this.attachInternals();
13+
}
14+
15+
@api
16+
hasElementInternalsBeenSet() {
17+
return Boolean(this.internals);
18+
}
19+
}

packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/api/index.spec.js

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createElement } from 'lwc';
22

33
import ShadowDomCmp from 'ai/shadowDom';
4+
import SyntheticShadowDomCmp from 'ai/syntheticShadowDom';
45
import LightDomCmp from 'ai/lightDom';
56
import BasicCmp from 'ai/basic';
67
import {
@@ -67,13 +68,7 @@ describe.runIf(ENABLE_ELEMENT_INTERNALS_AND_FACE)('ElementInternals', () => {
6768
});
6869

6970
describe.skipIf(process.env.NATIVE_SHADOW)('synthetic shadow', () => {
70-
it('should throw error when used inside a component', () => {
71-
const elm = createElement('synthetic-shadow', { is: ShadowDomCmp });
72-
testConnectedCallbackError(
73-
elm,
74-
'attachInternals API is not supported in synthetic shadow.'
75-
);
76-
});
71+
attachInternalsSanityTest('synthetic-shadow', SyntheticShadowDomCmp);
7772
});
7873

7974
describe('light DOM', () => {

packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/index.spec.js

Lines changed: 109 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,43 +6,117 @@ import FormAssociatedFalse from 'x/formAssociatedFalse';
66
import NotFormAssociatedNoAttachInternals from 'x/notFormAssociatedNoAttachInternals';
77
import FormAssociatedNoAttachInternals from 'x/formAssociatedNoAttachInternals';
88
import FormAssociatedFalseNoAttachInternals from 'x/formAssociatedFalseNoAttachInternals';
9-
import {
10-
ENABLE_ELEMENT_INTERNALS_AND_FACE,
11-
IS_SYNTHETIC_SHADOW_LOADED,
12-
} from '../../../../../helpers/constants.js';
13-
14-
describe.runIf(
15-
ENABLE_ELEMENT_INTERNALS_AND_FACE &&
16-
typeof ElementInternals !== 'undefined' &&
17-
!IS_SYNTHETIC_SHADOW_LOADED
18-
)('should throw an error when duplicate tag name used', () => {
19-
it('with different formAssociated value', () => {
20-
// Register tag with formAssociated = true
21-
createElement('x-form-associated', { is: FormAssociated });
22-
// Try to register again with formAssociated = false
23-
expect(() => createElement('x-form-associated', { is: FormAssociatedFalse })).toThrowError(
24-
/<x-form-associated> was already registered with formAssociated=true. It cannot be re-registered with formAssociated=false. Please rename your component to have a different name than <x-form-associated>/
25-
);
26-
});
9+
import { ENABLE_ELEMENT_INTERNALS_AND_FACE } from '../../../../../helpers/constants.js';
2710

28-
it('should not throw when duplicate tag name used with the same formAssociated value', () => {
29-
// formAssociated = true
30-
createElement('x-form-associated', { is: FormAssociated });
31-
expect(() => createElement('x-form-associated', { is: FormAssociated })).not.toThrow();
32-
// formAssociated = false
33-
createElement('x-form-associated-false', { is: FormAssociatedFalse });
34-
expect(() =>
35-
createElement('x-form-associated-false', { is: FormAssociatedFalse })
36-
).not.toThrow();
37-
// formAssociated = undefined
38-
createElement('x-not-form-associated', { is: NotFormAssociated });
39-
expect(() =>
40-
createElement('x-not-form-associated', { is: NotFormAssociated })
41-
).not.toThrow();
42-
});
43-
});
11+
describe.runIf(ENABLE_ELEMENT_INTERNALS_AND_FACE && typeof ElementInternals !== 'undefined')(
12+
'should throw an error when duplicate tag name used',
13+
() => {
14+
it('with different formAssociated value', () => {
15+
// Register tag with formAssociated = true
16+
createElement('x-form-associated', { is: FormAssociated });
17+
// Try to register again with formAssociated = false
18+
expect(() =>
19+
createElement('x-form-associated', { is: FormAssociatedFalse })
20+
).toThrowError(
21+
/<x-form-associated> was already registered with formAssociated=true. It cannot be re-registered with formAssociated=false. Please rename your component to have a different name than <x-form-associated>/
22+
);
23+
});
24+
25+
it('should not throw when duplicate tag name used with the same formAssociated value', () => {
26+
// formAssociated = true
27+
createElement('x-form-associated', { is: FormAssociated });
28+
expect(() => createElement('x-form-associated', { is: FormAssociated })).not.toThrow();
29+
// formAssociated = false
30+
createElement('x-form-associated-false', { is: FormAssociatedFalse });
31+
expect(() =>
32+
createElement('x-form-associated-false', { is: FormAssociatedFalse })
33+
).not.toThrow();
34+
// formAssociated = undefined
35+
createElement('x-not-form-associated', { is: NotFormAssociated });
36+
expect(() =>
37+
createElement('x-not-form-associated', { is: NotFormAssociated })
38+
).not.toThrow();
39+
});
40+
41+
it('should throw an error when accessing form related properties on a non-form associated component', () => {
42+
const form = document.createElement('form');
43+
document.body.appendChild(form);
44+
45+
const testElements = {
46+
'x-form-associated-false': FormAssociatedFalse,
47+
'x-not-form-associated': NotFormAssociated,
48+
};
49+
let elm;
50+
Object.entries(testElements).forEach(([tagName, ctor]) => {
51+
elm = createElement(`x-${tagName}`, { is: ctor });
52+
const { internals } = elm;
53+
form.appendChild(elm);
54+
expect(() => internals.form).toThrow();
55+
expect(() => internals.setFormValue('2019-03-15')).toThrow();
56+
expect(() => internals.willValidate).toThrow();
57+
expect(() => internals.validity).toThrow();
58+
expect(() => internals.checkValidity()).toThrow();
59+
expect(() => internals.reportValidity()).toThrow();
60+
expect(() => internals.setValidity('')).toThrow();
61+
expect(() => internals.validationMessage).toThrow();
62+
expect(() => internals.labels).toThrow();
63+
});
64+
document.body.removeChild(form);
65+
});
66+
67+
it('should be able to use internals to validate form associated component', () => {
68+
const elm = createElement('x-form-associated', { is: FormAssociated });
69+
const { internals } = elm;
70+
expect(internals.willValidate).toBe(true);
71+
expect(internals.validity.valid).toBe(true);
72+
expect(internals.checkValidity()).toBe(true);
73+
expect(internals.reportValidity()).toBe(true);
74+
expect(internals.validationMessage).toBe('');
75+
76+
internals.setValidity({ rangeUnderflow: true }, 'pick future date');
77+
78+
expect(internals.validity.valid).toBe(false);
79+
expect(internals.checkValidity()).toBe(false);
80+
expect(internals.reportValidity()).toBe(false);
81+
expect(internals.validationMessage).toBe('pick future date');
82+
});
83+
84+
it('should be able to use setFormValue on a form associated component', () => {
85+
const form = document.createElement('form');
86+
document.body.appendChild(form);
87+
88+
const elm = createElement('x-form-associated', { is: FormAssociated });
89+
const { internals } = elm;
90+
form.appendChild(elm);
91+
92+
expect(internals.form).toBe(form);
93+
94+
elm.setAttribute('name', 'date');
95+
const inputElm = elm.shadowRoot
96+
.querySelector('x-input')
97+
.shadowRoot.querySelector('input');
98+
internals.setFormValue('2019-03-15', '3/15/2019', inputElm);
99+
const formData = new FormData(form);
100+
expect(formData.get('date')).toBe('2019-03-15');
101+
});
102+
103+
it('should be able to associate labels to a form associated component', () => {
104+
const elm = createElement('x-form-associated', { is: FormAssociated });
105+
document.body.appendChild(elm);
106+
const { internals } = elm;
107+
108+
expect(internals.labels.length).toBe(0);
109+
elm.id = 'test-id';
110+
const label = document.createElement('label');
111+
label.htmlFor = elm.id;
112+
document.body.appendChild(label);
113+
expect(internals.labels.length).toBe(1);
114+
expect(internals.labels[0]).toBe(label);
115+
});
116+
}
117+
);
44118

45-
it.runIf(typeof ElementInternals !== 'undefined' && !IS_SYNTHETIC_SHADOW_LOADED)(
119+
it.runIf(typeof ElementInternals === 'undefined')(
46120
'disallows form association on older API versions',
47121
() => {
48122
const isFormAssociated = (elm) => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<template>
2+
<x-input></x-input>
3+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<template>
2+
<input />
3+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { LightningElement } from 'lwc';
2+
3+
export default class extends LightningElement {}

packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/sanity/ei/component/component.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,13 @@ export default class extends LightningElement {
1919
this.internals[prop] = value;
2020
}
2121
}
22+
23+
@api
24+
toggleChecked() {
25+
if (!this.internals.states.has('--checked')) {
26+
this.internals.states.add('--checked');
27+
} else {
28+
this.internals.states.delete('--checked');
29+
}
30+
}
2231
}

0 commit comments

Comments
 (0)