@servicetitan/web-components 36.0.0 → 36.1.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/globals.d.ts +3 -3
  2. package/dist/globals.d.ts.map +1 -1
  3. package/dist/globals.js +19 -4
  4. package/dist/globals.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/register-headless.d.ts.map +1 -1
  10. package/dist/register-headless.js +1 -0
  11. package/dist/register-headless.js.map +1 -1
  12. package/dist/register.d.ts.map +1 -1
  13. package/dist/register.js +38 -36
  14. package/dist/register.js.map +1 -1
  15. package/dist/render.d.ts.map +1 -1
  16. package/dist/render.js +3 -11
  17. package/dist/render.js.map +1 -1
  18. package/dist/types.d.ts +1 -0
  19. package/dist/types.d.ts.map +1 -1
  20. package/dist/types.js.map +1 -1
  21. package/dist/utils/get-bundle-info.d.ts +1 -0
  22. package/dist/utils/get-bundle-info.d.ts.map +1 -1
  23. package/dist/utils/get-bundle-info.js +14 -7
  24. package/dist/utils/get-bundle-info.js.map +1 -1
  25. package/dist/utils/get-entry-points.d.ts +2 -0
  26. package/dist/utils/get-entry-points.d.ts.map +1 -1
  27. package/dist/utils/get-entry-points.js.map +1 -1
  28. package/dist/utils/get-ld-service-token.d.ts +2 -3
  29. package/dist/utils/get-ld-service-token.d.ts.map +1 -1
  30. package/dist/utils/get-ld-service-token.js +2 -7
  31. package/dist/utils/get-ld-service-token.js.map +1 -1
  32. package/dist/utils/load-scripts.d.ts +1 -1
  33. package/dist/utils/load-scripts.d.ts.map +1 -1
  34. package/dist/utils/load-scripts.js +6 -2
  35. package/dist/utils/load-scripts.js.map +1 -1
  36. package/dist/with-css-injector.d.ts +3 -0
  37. package/dist/with-css-injector.d.ts.map +1 -0
  38. package/dist/with-css-injector.js +52 -0
  39. package/dist/with-css-injector.js.map +1 -0
  40. package/package.json +21 -12
  41. package/src/__mocks__/register-anvil2-harness.tsx +49 -0
  42. package/src/__mocks__/test-component.tsx +9 -2
  43. package/src/__tests__/loader.test.tsx +12 -0
  44. package/src/__tests__/register-anvil2-optional.test.tsx +32 -0
  45. package/src/__tests__/register-anvil2.test.tsx +11 -64
  46. package/src/__tests__/with-css-injector.test.tsx +152 -0
  47. package/src/globals.ts +30 -10
  48. package/src/index.ts +1 -0
  49. package/src/register-headless.ts +1 -0
  50. package/src/register.tsx +48 -42
  51. package/src/render.ts +3 -14
  52. package/src/types.ts +1 -1
  53. package/src/utils/get-bundle-info.ts +14 -7
  54. package/src/utils/get-entry-points.ts +2 -0
  55. package/src/utils/get-ld-service-token.ts +2 -10
  56. package/src/utils/load-scripts.ts +7 -2
  57. package/src/with-css-injector.tsx +45 -0
@@ -1 +1 @@
1
- {"version":3,"file":"load-scripts.d.ts","sourceRoot":"","sources":["../../src/utils/load-scripts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAE/C,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,EAAE,UAAU,mBAmC7D"}
1
+ {"version":3,"file":"load-scripts.d.ts","sourceRoot":"","sources":["../../src/utils/load-scripts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAE/C,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,EAAE,UAAU,mBAwCrE"}
@@ -1,16 +1,20 @@
1
- export function loadScripts({ urls, WebComponent }) {
1
+ export function loadScripts({ urls, WebComponent, module }) {
2
2
  return Promise.all(urls.js.map((url)=>{
3
3
  const script = Array.from(document.scripts).find(({ src })=>src === url);
4
4
  if (!script) {
5
5
  return new Promise((resolve)=>{
6
6
  const script = document.createElement('script');
7
+ if (module) {
8
+ script.type = 'module';
9
+ } else {
10
+ script.async = true;
11
+ }
7
12
  script.crossOrigin = 'anonymous';
8
13
  script.onload = ()=>{
9
14
  script.dataset.webComponent = WebComponent;
10
15
  script.onload = null;
11
16
  resolve();
12
17
  };
13
- script.async = true;
14
18
  script.src = url;
15
19
  document.body.append(script);
16
20
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/load-scripts.ts"],"sourcesContent":["import { BundleInfo } from './get-bundle-info';\n\nexport function loadScripts({ urls, WebComponent }: BundleInfo) {\n return Promise.all(\n urls.js.map(url => {\n const script = Array.from(document.scripts).find(({ src }) => src === url);\n if (!script) {\n return new Promise<void>(resolve => {\n const script = document.createElement('script');\n\n script.crossOrigin = 'anonymous';\n script.onload = () => {\n script.dataset.webComponent = WebComponent;\n script.onload = null;\n resolve();\n };\n script.async = true;\n script.src = url;\n\n document.body.append(script);\n });\n }\n\n if (!script.dataset.webComponent) {\n return new Promise<void>(resolve => {\n const originalOnload = script.onload;\n script.onload = (...args) => {\n originalOnload?.call(script, ...args);\n script.onload = null;\n resolve();\n };\n });\n }\n\n return Promise.resolve();\n })\n );\n}\n"],"names":["loadScripts","urls","WebComponent","Promise","all","js","map","url","script","Array","from","document","scripts","find","src","resolve","createElement","crossOrigin","onload","dataset","webComponent","async","body","append","originalOnload","args","call"],"mappings":"AAEA,OAAO,SAASA,YAAY,EAAEC,IAAI,EAAEC,YAAY,EAAc;IAC1D,OAAOC,QAAQC,GAAG,CACdH,KAAKI,EAAE,CAACC,GAAG,CAACC,CAAAA;QACR,MAAMC,SAASC,MAAMC,IAAI,CAACC,SAASC,OAAO,EAAEC,IAAI,CAAC,CAAC,EAAEC,GAAG,EAAE,GAAKA,QAAQP;QACtE,IAAI,CAACC,QAAQ;YACT,OAAO,IAAIL,QAAcY,CAAAA;gBACrB,MAAMP,SAASG,SAASK,aAAa,CAAC;gBAEtCR,OAAOS,WAAW,GAAG;gBACrBT,OAAOU,MAAM,GAAG;oBACZV,OAAOW,OAAO,CAACC,YAAY,GAAGlB;oBAC9BM,OAAOU,MAAM,GAAG;oBAChBH;gBACJ;gBACAP,OAAOa,KAAK,GAAG;gBACfb,OAAOM,GAAG,GAAGP;gBAEbI,SAASW,IAAI,CAACC,MAAM,CAACf;YACzB;QACJ;QAEA,IAAI,CAACA,OAAOW,OAAO,CAACC,YAAY,EAAE;YAC9B,OAAO,IAAIjB,QAAcY,CAAAA;gBACrB,MAAMS,iBAAiBhB,OAAOU,MAAM;gBACpCV,OAAOU,MAAM,GAAG,CAAC,GAAGO;oBAChBD,2BAAAA,qCAAAA,eAAgBE,IAAI,CAAClB,WAAWiB;oBAChCjB,OAAOU,MAAM,GAAG;oBAChBH;gBACJ;YACJ;QACJ;QAEA,OAAOZ,QAAQY,OAAO;IAC1B;AAER"}
1
+ {"version":3,"sources":["../../src/utils/load-scripts.ts"],"sourcesContent":["import { BundleInfo } from './get-bundle-info';\n\nexport function loadScripts({ urls, WebComponent, module }: BundleInfo) {\n return Promise.all(\n urls.js.map(url => {\n const script = Array.from(document.scripts).find(({ src }) => src === url);\n if (!script) {\n return new Promise<void>(resolve => {\n const script = document.createElement('script');\n\n if (module) {\n script.type = 'module';\n } else {\n script.async = true;\n }\n\n script.crossOrigin = 'anonymous';\n script.onload = () => {\n script.dataset.webComponent = WebComponent;\n script.onload = null;\n resolve();\n };\n script.src = url;\n\n document.body.append(script);\n });\n }\n\n if (!script.dataset.webComponent) {\n return new Promise<void>(resolve => {\n const originalOnload = script.onload;\n script.onload = (...args) => {\n originalOnload?.call(script, ...args);\n script.onload = null;\n resolve();\n };\n });\n }\n\n return Promise.resolve();\n })\n );\n}\n"],"names":["loadScripts","urls","WebComponent","module","Promise","all","js","map","url","script","Array","from","document","scripts","find","src","resolve","createElement","type","async","crossOrigin","onload","dataset","webComponent","body","append","originalOnload","args","call"],"mappings":"AAEA,OAAO,SAASA,YAAY,EAAEC,IAAI,EAAEC,YAAY,EAAEC,MAAM,EAAc;IAClE,OAAOC,QAAQC,GAAG,CACdJ,KAAKK,EAAE,CAACC,GAAG,CAACC,CAAAA;QACR,MAAMC,SAASC,MAAMC,IAAI,CAACC,SAASC,OAAO,EAAEC,IAAI,CAAC,CAAC,EAAEC,GAAG,EAAE,GAAKA,QAAQP;QACtE,IAAI,CAACC,QAAQ;YACT,OAAO,IAAIL,QAAcY,CAAAA;gBACrB,MAAMP,SAASG,SAASK,aAAa,CAAC;gBAEtC,IAAId,QAAQ;oBACRM,OAAOS,IAAI,GAAG;gBAClB,OAAO;oBACHT,OAAOU,KAAK,GAAG;gBACnB;gBAEAV,OAAOW,WAAW,GAAG;gBACrBX,OAAOY,MAAM,GAAG;oBACZZ,OAAOa,OAAO,CAACC,YAAY,GAAGrB;oBAC9BO,OAAOY,MAAM,GAAG;oBAChBL;gBACJ;gBACAP,OAAOM,GAAG,GAAGP;gBAEbI,SAASY,IAAI,CAACC,MAAM,CAAChB;YACzB;QACJ;QAEA,IAAI,CAACA,OAAOa,OAAO,CAACC,YAAY,EAAE;YAC9B,OAAO,IAAInB,QAAcY,CAAAA;gBACrB,MAAMU,iBAAiBjB,OAAOY,MAAM;gBACpCZ,OAAOY,MAAM,GAAG,CAAC,GAAGM;oBAChBD,2BAAAA,qCAAAA,eAAgBE,IAAI,CAACnB,WAAWkB;oBAChClB,OAAOY,MAAM,GAAG;oBAChBL;gBACJ;YACJ;QACJ;QAEA,OAAOZ,QAAQY,OAAO;IAC1B;AAER"}
@@ -0,0 +1,3 @@
1
+ import { ComponentType } from 'react';
2
+ export declare function withCssInjector<P extends object>(Component: ComponentType<P>): ComponentType<P>;
3
+ //# sourceMappingURL=with-css-injector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"with-css-injector.d.ts","sourceRoot":"","sources":["../src/with-css-injector.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAa,MAAM,OAAO,CAAC;AAIjD,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,EAAE,SAAS,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAwC/F"}
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import { useMFEMetadataContext } from './contexts/mfe-metadata-context';
4
+ import { WEB_COMPONENT_NAME } from './globals';
5
+ export function withCssInjector(Component) {
6
+ return function CssInjectorWrapper(props) {
7
+ const { shadowRoot, portalShadowRoot } = useMFEMetadataContext();
8
+ useEffect(()=>{
9
+ var _window, ___mfeStyles__, _mfeStyles, _WEB_COMPONENT_NAME, _;
10
+ (___mfeStyles__ = (_window = window).__mfeStyles__) !== null && ___mfeStyles__ !== void 0 ? ___mfeStyles__ : _window.__mfeStyles__ = {};
11
+ const mfeStyles = window.__mfeStyles__;
12
+ (_ = (_mfeStyles = mfeStyles)[_WEB_COMPONENT_NAME = WEB_COMPONENT_NAME]) !== null && _ !== void 0 ? _ : _mfeStyles[_WEB_COMPONENT_NAME] = Object.assign(new EventTarget(), {
13
+ sheets: new Map()
14
+ });
15
+ const emitter = mfeStyles[WEB_COMPONENT_NAME];
16
+ /*
17
+ * In self-hosted standalone dev mode (set by index.html), also adopt sheets
18
+ * into document so host-only CSS vars and fonts propagate to the page.
19
+ */ const roots = [
20
+ shadowRoot,
21
+ portalShadowRoot,
22
+ ...window.__mfeSelfHosted__ ? [
23
+ document
24
+ ] : []
25
+ ];
26
+ function adoptSheet(sheet) {
27
+ for (const root of roots){
28
+ root.adoptedStyleSheets = [
29
+ ...root.adoptedStyleSheets,
30
+ sheet
31
+ ];
32
+ }
33
+ }
34
+ for (const sheet of emitter.sheets.values()){
35
+ adoptSheet(sheet);
36
+ }
37
+ const handleNewSheet = ({ detail: sheetId })=>{
38
+ adoptSheet(emitter.sheets.get(sheetId));
39
+ };
40
+ emitter.addEventListener('new-sheet', handleNewSheet);
41
+ return ()=>emitter.removeEventListener('new-sheet', handleNewSheet);
42
+ }, [
43
+ shadowRoot,
44
+ portalShadowRoot
45
+ ]);
46
+ return /*#__PURE__*/ _jsx(Component, {
47
+ ...props
48
+ });
49
+ };
50
+ }
51
+
52
+ //# sourceMappingURL=with-css-injector.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/with-css-injector.tsx"],"sourcesContent":["import { ComponentType, useEffect } from 'react';\nimport { useMFEMetadataContext } from './contexts/mfe-metadata-context';\nimport { WEB_COMPONENT_NAME } from './globals';\n\nexport function withCssInjector<P extends object>(Component: ComponentType<P>): ComponentType<P> {\n return function CssInjectorWrapper(props: P) {\n const { shadowRoot, portalShadowRoot } = useMFEMetadataContext();\n\n useEffect(() => {\n window.__mfeStyles__ ??= {};\n const mfeStyles = window.__mfeStyles__;\n mfeStyles[WEB_COMPONENT_NAME] ??= Object.assign(new EventTarget(), {\n sheets: new Map<string, CSSStyleSheet>(),\n });\n const emitter = mfeStyles[WEB_COMPONENT_NAME];\n /*\n * In self-hosted standalone dev mode (set by index.html), also adopt sheets\n * into document so host-only CSS vars and fonts propagate to the page.\n */\n const roots = [\n shadowRoot,\n portalShadowRoot,\n ...(window.__mfeSelfHosted__ ? [document] : []),\n ];\n function adoptSheet(sheet: CSSStyleSheet) {\n for (const root of roots) {\n root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];\n }\n }\n\n for (const sheet of emitter.sheets.values()) {\n adoptSheet(sheet);\n }\n\n const handleNewSheet = ({ detail: sheetId }: CustomEvent<string>) => {\n adoptSheet(emitter.sheets.get(sheetId)!);\n };\n\n emitter.addEventListener('new-sheet', handleNewSheet);\n return () => emitter.removeEventListener('new-sheet', handleNewSheet);\n }, [shadowRoot, portalShadowRoot]);\n\n return <Component {...props} />;\n };\n}\n"],"names":["useEffect","useMFEMetadataContext","WEB_COMPONENT_NAME","withCssInjector","Component","CssInjectorWrapper","props","shadowRoot","portalShadowRoot","window","mfeStyles","__mfeStyles__","Object","assign","EventTarget","sheets","Map","emitter","roots","__mfeSelfHosted__","document","adoptSheet","sheet","root","adoptedStyleSheets","values","handleNewSheet","detail","sheetId","get","addEventListener","removeEventListener"],"mappings":";AAAA,SAAwBA,SAAS,QAAQ,QAAQ;AACjD,SAASC,qBAAqB,QAAQ,kCAAkC;AACxE,SAASC,kBAAkB,QAAQ,YAAY;AAE/C,OAAO,SAASC,gBAAkCC,SAA2B;IACzE,OAAO,SAASC,mBAAmBC,KAAQ;QACvC,MAAM,EAAEC,UAAU,EAAEC,gBAAgB,EAAE,GAAGP;QAEzCD,UAAU;gBACNS,SAAAA,gBAEAC,YAAUR,qBAAVQ;aAFAD,iBAAAA,CAAAA,UAAAA,QAAOE,aAAa,cAApBF,4BAAAA,iBAAAA,QAAOE,aAAa,GAAK,CAAC;YAC1B,MAAMD,YAAYD,OAAOE,aAAa;aACtCD,IAAAA,CAAAA,aAAAA,UAAS,CAACR,sBAAAA,mBAAmB,cAA7BQ,eAAAA,IAAAA,UAAS,CAACR,oBAAmB,GAAKU,OAAOC,MAAM,CAAC,IAAIC,eAAe;gBAC/DC,QAAQ,IAAIC;YAChB;YACA,MAAMC,UAAUP,SAAS,CAACR,mBAAmB;YAC7C;;;aAGC,GACD,MAAMgB,QAAQ;gBACVX;gBACAC;mBACIC,OAAOU,iBAAiB,GAAG;oBAACC;iBAAS,GAAG,EAAE;aACjD;YACD,SAASC,WAAWC,KAAoB;gBACpC,KAAK,MAAMC,QAAQL,MAAO;oBACtBK,KAAKC,kBAAkB,GAAG;2BAAID,KAAKC,kBAAkB;wBAAEF;qBAAM;gBACjE;YACJ;YAEA,KAAK,MAAMA,SAASL,QAAQF,MAAM,CAACU,MAAM,GAAI;gBACzCJ,WAAWC;YACf;YAEA,MAAMI,iBAAiB,CAAC,EAAEC,QAAQC,OAAO,EAAuB;gBAC5DP,WAAWJ,QAAQF,MAAM,CAACc,GAAG,CAACD;YAClC;YAEAX,QAAQa,gBAAgB,CAAC,aAAaJ;YACtC,OAAO,IAAMT,QAAQc,mBAAmB,CAAC,aAAaL;QAC1D,GAAG;YAACnB;YAAYC;SAAiB;QAEjC,qBAAO,KAACJ;YAAW,GAAGE,KAAK;;IAC/B;AACJ"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@servicetitan/web-components",
3
- "version": "36.0.0",
3
+ "version": "36.1.1-beta.1",
4
4
  "description": "Framework to build, register, and load Microfrontends into host applications",
5
5
  "homepage": "https://docs.st.dev/docs/frontend/uikit/web-components",
6
6
  "repository": {
@@ -10,24 +10,34 @@
10
10
  },
11
11
  "main": "./dist/index.js",
12
12
  "typings": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "./register-headless": {
19
+ "types": "./dist/register-headless.d.ts",
20
+ "default": "./dist/register-headless.js"
21
+ }
22
+ },
13
23
  "sideEffects": false,
14
24
  "files": [
15
25
  "dist",
16
26
  "src"
17
27
  ],
18
28
  "dependencies": {
19
- "@servicetitan/error-boundary": "36.0.0",
20
- "@servicetitan/startup-utils": "36.0.0",
29
+ "@servicetitan/error-boundary": "36.1.1-beta.1",
30
+ "@servicetitan/startup-utils": "36.1.1-beta.1",
21
31
  "lodash.get": "~4.4.2",
22
32
  "mitt": "~3.0.1",
23
33
  "semver": "~7.7.4"
24
34
  },
25
35
  "devDependencies": {
26
- "@servicetitan/anvil2": "^2.7.0",
36
+ "@servicetitan/anvil2": "^2.8.0",
27
37
  "@servicetitan/design-system": "~14.5.3",
28
- "@servicetitan/launchdarkly-service": "36.0.0",
29
- "@servicetitan/log-service": "36.0.0",
30
- "@servicetitan/react-ioc": "36.0.0",
38
+ "@servicetitan/launchdarkly-service": "36.1.1-beta.1",
39
+ "@servicetitan/log-service": "36.1.1-beta.1",
40
+ "@servicetitan/react-ioc": "36.1.1-beta.1",
31
41
  "@testing-library/dom": "^10.4.1",
32
42
  "@testing-library/jest-dom": "^6.9.1",
33
43
  "@testing-library/react": "^16.3.2",
@@ -45,9 +55,9 @@
45
55
  "peerDependencies": {
46
56
  "@servicetitan/anvil2": ">=1.5.0",
47
57
  "@servicetitan/design-system": ">=14.5.3",
48
- "@servicetitan/launchdarkly-service": "36.0.0",
49
- "@servicetitan/log-service": "36.0.0",
50
- "@servicetitan/react-ioc": "36.0.0",
58
+ "@servicetitan/launchdarkly-service": "36.1.1-beta.1",
59
+ "@servicetitan/log-service": "36.1.1-beta.1",
60
+ "@servicetitan/react-ioc": "36.1.1-beta.1",
51
61
  "history": "~4.10.1",
52
62
  "react": ">=17.0.2",
53
63
  "react-dom": ">=17.0.2"
@@ -68,6 +78,5 @@
68
78
  },
69
79
  "cli": {
70
80
  "webpack": false
71
- },
72
- "gitHead": "73584720991027dd55eaeb24fb8e09bad656dbad"
81
+ }
73
82
  }
@@ -0,0 +1,49 @@
1
+ import { render } from '@testing-library/react';
2
+ import { MockComponent, registerComponent } from './index';
3
+
4
+ /* Wrapper components injected by each optional library. */
5
+ export const optionalLibraryWrappers: Record<string, string[]> = {
6
+ '@servicetitan/anvil2': ['PortalProvider'],
7
+ '@servicetitan/design-system': [
8
+ 'DefaultPortalContext.Provider',
9
+ 'TablePopupPropsContext.Provider',
10
+ ],
11
+ };
12
+
13
+ export interface RegisterHarness {
14
+ Page: {
15
+ readonly componentShadowRoot: ShadowRoot;
16
+ readonly portalShadowRoot: ShadowRoot;
17
+ };
18
+ subject: () => ReturnType<typeof render>;
19
+ }
20
+
21
+ export function createRegisterHarness(
22
+ componentName: string,
23
+ options?: Parameters<typeof registerComponent>[2]
24
+ ): RegisterHarness {
25
+ const Page = {
26
+ get componentShadowRoot() {
27
+ return document.querySelector(componentName)!.shadowRoot!;
28
+ },
29
+ get portalShadowRoot() {
30
+ return document.querySelector('body')!.lastElementChild!.shadowRoot!;
31
+ },
32
+ };
33
+
34
+ beforeAll(() => {
35
+ registerComponent(MockComponent, componentName, {
36
+ sharedDependenciesNames: ['@servicetitan/anvil2'],
37
+ ...options,
38
+ });
39
+ });
40
+
41
+ const subject = () => {
42
+ jest.spyOn(console, 'error').mockImplementation(jest.fn()); // suppress "ReactDOM.render deprecated" error
43
+ const Component: any = componentName;
44
+ const handleRef = (wc: any) => wc?.provide({ foo: 'bar' }); // triggers rendering
45
+ return render(<Component ref={handleRef} />);
46
+ };
47
+
48
+ return { Page, subject };
49
+ }
@@ -1,13 +1,20 @@
1
1
  import { TablePopupPropsContext } from '@servicetitan/design-system';
2
- import { FC, useContext } from 'react';
2
+ import { createContext, FC, useContext } from 'react';
3
3
  import { useMFEDataContext, useMFEMetadataContext } from '../contexts';
4
4
 
5
5
  jest.mock('@progress/kendo-licensing', () => ({ validatePackage: jest.fn() }));
6
6
 
7
+ const FallbackContext = createContext(undefined);
8
+
7
9
  export const MockComponent: FC<Record<string, any>> = props => {
8
10
  const data = useMFEDataContext();
9
11
  const metadata = useMFEMetadataContext();
10
- const popupProps = useContext(TablePopupPropsContext);
12
+ /*
13
+ * TablePopupPropsContext is undefined when a test mocks
14
+ * @servicetitan/design-system as an empty object. Fall back to
15
+ * a no-op context so the component renders without it.
16
+ */
17
+ const popupProps = useContext(TablePopupPropsContext ?? FallbackContext);
11
18
  const appendTo = popupProps?.({}).appendTo;
12
19
 
13
20
  return (
@@ -403,6 +403,18 @@ describe('[web-components] Loader', () => {
403
403
  () => `${baseUrl}/dist/bundle/light/${metadata.entrypoints!.light.css[0]}`
404
404
  );
405
405
 
406
+ describe('when entrypoint has module: true', () => {
407
+ beforeEach(() => {
408
+ metadata.entrypoints!.light.module = true;
409
+ });
410
+
411
+ test('creates script with type="module"', async () => {
412
+ await scriptLoadedSetup();
413
+
414
+ expect(document.querySelector('script')).toHaveAttribute('type', 'module');
415
+ });
416
+ });
417
+
406
418
  describe('when the startup version is < 17.0.0', () => {
407
419
  beforeEach(() => {
408
420
  setStartupVersion('16.0.0');
@@ -0,0 +1,32 @@
1
+ /*
2
+ * Split from register-anvil2.test.tsx because register.tsx changed from
3
+ * dynamic require() to static `import * as` for Vite/ESM compatibility.
4
+ * The previous approach used jest.resetModules() mid-file to re-mock
5
+ * modules between tests, but static imports are resolved once at module
6
+ * load time and aren't affected by resetModules. Separate files give
7
+ * each test suite its own jest.mock declarations.
8
+ */
9
+ import '@testing-library/jest-dom';
10
+ import { within } from '@testing-library/react';
11
+ import {
12
+ createRegisterHarness,
13
+ optionalLibraryWrappers,
14
+ } from '../__mocks__/register-anvil2-harness';
15
+
16
+ jest.mock('@servicetitan/anvil2', () => ({}));
17
+ jest.mock('@servicetitan/design-system', () => ({}));
18
+
19
+ describe('[web-components] register', () => {
20
+ const { Page, subject } = createRegisterHarness('mock-no-optional-deps');
21
+
22
+ for (const [library, wrappers] of Object.entries(optionalLibraryWrappers)) {
23
+ describe(`when ${library} is not present`, () => {
24
+ test.each(wrappers)('does not wrap component within %s', name => {
25
+ subject();
26
+
27
+ const element = within(Page.componentShadowRoot as any).queryByText(`<${name}>`);
28
+ expect(element).not.toBeInTheDocument();
29
+ });
30
+ });
31
+ }
32
+ });
@@ -1,7 +1,11 @@
1
1
  import '@testing-library/jest-dom';
2
- import { render, within } from '@testing-library/react';
2
+ import { within } from '@testing-library/react';
3
3
  import { Fragment } from 'react';
4
- import { expectStylesInDom, MockComponent, registerComponent } from '../__mocks__';
4
+ import { expectStylesInDom } from '../__mocks__';
5
+ import {
6
+ createRegisterHarness,
7
+ optionalLibraryWrappers,
8
+ } from '../__mocks__/register-anvil2-harness';
5
9
 
6
10
  function mockComponent(name: string) {
7
11
  return {
@@ -37,30 +41,7 @@ jest.mock('@servicetitan/design-system', () => ({
37
41
  }));
38
42
 
39
43
  describe('[web-components] register', () => {
40
- const componentName = 'mock-anvil2-app';
41
-
42
- class Page {
43
- static get componentShadowRoot() {
44
- return document.querySelector(componentName)!.shadowRoot!;
45
- }
46
-
47
- static get portalShadowRoot() {
48
- return document.querySelector('body')!.lastElementChild!.shadowRoot!;
49
- }
50
- }
51
-
52
- beforeAll(() => {
53
- registerComponent(MockComponent, componentName, {
54
- sharedDependenciesNames: ['@servicetitan/anvil2'],
55
- });
56
- });
57
-
58
- const subject = () => {
59
- jest.spyOn(console, 'error').mockImplementation(jest.fn()); // suppress "ReactDOM.render deprecated" error
60
- const Component: any = componentName;
61
- const handleRef = (webComponent: any) => webComponent?.provide({ foo: 'bar' }); // triggers rendering
62
- return render(<Component ref={handleRef} />);
63
- };
44
+ const { Page, subject } = createRegisterHarness('mock-anvil2-app');
64
45
 
65
46
  describe('when Anvil2 bundle is present in the DOM', () => {
66
47
  let cssLink: HTMLLinkElement;
@@ -90,44 +71,10 @@ describe('[web-components] register', () => {
90
71
  });
91
72
  });
92
73
 
93
- function itWrapsComponentWithin(name: string, expected: boolean = true) {
94
- test(`${expected ? 'wraps' : 'does not wrap'} component within ${name}`, () => {
95
- subject();
96
-
97
- const element = within(Page.componentShadowRoot as any).queryByText(`<${name}>`);
98
- if (expected) {
99
- expect(element).toBeInTheDocument();
100
- } else {
101
- expect(element).not.toBeInTheDocument();
102
- }
103
- });
104
- }
105
-
106
- ['PortalProvider', 'DefaultPortalContext.Provider', 'TablePopupPropsContext.Provider'].forEach(
107
- name => itWrapsComponentWithin(name)
108
- );
109
-
110
- describe('when @servicetitan/anvil2 is not present', () => {
111
- beforeAll(() => {
112
- jest.resetModules();
113
- jest.mock('@servicetitan/anvil2', () => {
114
- throw new Error('Cannot find module');
115
- });
116
- });
117
-
118
- itWrapsComponentWithin('PortalProvider', false);
119
- });
120
-
121
- describe('when @servicetitan/design-system is not present', () => {
122
- beforeAll(() => {
123
- jest.resetModules();
124
- jest.mock('@servicetitan/design-system', () => {
125
- throw new Error('Cannot find module');
126
- });
127
- });
74
+ test.each(Object.values(optionalLibraryWrappers).flat())('wraps component within %s', name => {
75
+ subject();
128
76
 
129
- ['DefaultPortalContext.Provider', 'TablePopupPropsContext.Provider'].forEach(name =>
130
- itWrapsComponentWithin(name, false)
131
- );
77
+ const element = within(Page.componentShadowRoot as any).queryByText(`<${name}>`);
78
+ expect(element).toBeInTheDocument();
132
79
  });
133
80
  });
@@ -0,0 +1,152 @@
1
+ import { act, render } from '@testing-library/react';
2
+ import { FC } from 'react';
3
+ import { useMFEMetadataContext } from '../contexts/mfe-metadata-context';
4
+ import { WEB_COMPONENT_NAME } from '../globals';
5
+ import { withCssInjector } from '../with-css-injector';
6
+
7
+ jest.mock('../contexts/mfe-metadata-context');
8
+ jest.mock('../globals', () => ({
9
+ // eslint-disable-next-line @typescript-eslint/naming-convention
10
+ WEB_COMPONENT_NAME: 'test-mfe',
11
+ }));
12
+
13
+ describe(`[web-components] ${withCssInjector.name}`, () => {
14
+ const innerTestId = 'inner';
15
+ const innerLabel = 'foo';
16
+ const InnerComponent: FC<{ label: string }> = ({ label }) => (
17
+ <div data-testid={innerTestId}>{label}</div>
18
+ );
19
+ const WrappedComponent = withCssInjector(InnerComponent);
20
+
21
+ const componentName = WEB_COMPONENT_NAME as string;
22
+
23
+ let shadowRoot: ShadowRoot;
24
+ let portalShadowRoot: ShadowRoot;
25
+
26
+ beforeEach(() => {
27
+ jest.clearAllMocks();
28
+ delete window.__mfeStyles__;
29
+ delete window.__mfeSelfHosted__;
30
+ shadowRoot = document.createElement('div').attachShadow({ mode: 'open' });
31
+ portalShadowRoot = document.createElement('span').attachShadow({ mode: 'open' });
32
+ document.adoptedStyleSheets = [];
33
+ shadowRoot.adoptedStyleSheets = [];
34
+ portalShadowRoot.adoptedStyleSheets = [];
35
+ jest.mocked(useMFEMetadataContext).mockImplementation(() => ({
36
+ shadowRoot,
37
+ portalShadowRoot,
38
+ }));
39
+ });
40
+
41
+ const subject = () => render(<WrappedComponent label={innerLabel} />);
42
+
43
+ const getEmitter = () => window.__mfeStyles__?.[componentName];
44
+
45
+ test('renders the wrapped component with its props', () => {
46
+ const { getByTestId } = subject();
47
+
48
+ expect(getByTestId(innerTestId).textContent).toBe(innerLabel);
49
+ });
50
+
51
+ test('initializes the styles registry on window', () => {
52
+ subject();
53
+
54
+ expect(window.__mfeStyles__).toBeDefined();
55
+ });
56
+
57
+ test('registers an emitter for the component', () => {
58
+ subject();
59
+
60
+ expect(getEmitter()?.sheets).toBeInstanceOf(Map);
61
+ });
62
+
63
+ describe('with a preexisting sheet in the emitter', () => {
64
+ const sheetId = 'sheet-1';
65
+ let sheet: CSSStyleSheet;
66
+
67
+ beforeEach(() => {
68
+ sheet = new CSSStyleSheet();
69
+ const emitter = Object.assign(new EventTarget(), {
70
+ sheets: new Map([[sheetId, sheet]]),
71
+ });
72
+ window.__mfeStyles__ = { [componentName]: emitter };
73
+ });
74
+
75
+ test('adopts the sheet into the shadow root', () => {
76
+ subject();
77
+
78
+ expect(shadowRoot.adoptedStyleSheets).toContain(sheet);
79
+ });
80
+
81
+ test('adopts the sheet into the portal shadow root', () => {
82
+ subject();
83
+
84
+ expect(portalShadowRoot!.adoptedStyleSheets).toContain(sheet);
85
+ });
86
+
87
+ test('reuses the existing emitter', () => {
88
+ const existingEmitter = getEmitter();
89
+
90
+ subject();
91
+
92
+ expect(getEmitter()).toBe(existingEmitter);
93
+ });
94
+
95
+ describe('when self-hosted in standalone development', () => {
96
+ beforeEach(() => {
97
+ window.__mfeSelfHosted__ = true;
98
+ });
99
+
100
+ test('adopts the sheet into document', () => {
101
+ subject();
102
+
103
+ expect(document.adoptedStyleSheets).toContain(sheet);
104
+ });
105
+ });
106
+ });
107
+
108
+ describe('when already mounted', () => {
109
+ let unmount: ReturnType<typeof subject>['unmount'];
110
+ let emitter: NonNullable<ReturnType<typeof getEmitter>>;
111
+
112
+ beforeEach(() => {
113
+ unmount = subject().unmount;
114
+ emitter = getEmitter()!;
115
+ });
116
+
117
+ function emit(sheet: CSSStyleSheet) {
118
+ const id = Math.floor(Math.random() * 1e6).toString(36);
119
+
120
+ emitter.sheets.set(id, sheet);
121
+ act(() => {
122
+ emitter.dispatchEvent(new CustomEvent<string>('new-sheet', { detail: id }));
123
+ });
124
+ }
125
+
126
+ describe('when a new-sheet event is dispatched', () => {
127
+ const sheet = new CSSStyleSheet();
128
+
129
+ beforeEach(() => emit(sheet));
130
+
131
+ test('adopts the newly added sheet into the shadow root', () => {
132
+ expect(shadowRoot.adoptedStyleSheets).toContain(sheet);
133
+ });
134
+
135
+ test('adopts the newly added sheet into the portal shadow root', () => {
136
+ expect(portalShadowRoot!.adoptedStyleSheets).toContain(sheet);
137
+ });
138
+ });
139
+
140
+ describe('when unmounted', () => {
141
+ beforeEach(() => unmount());
142
+
143
+ test('stops adopting newly added sheets', () => {
144
+ const sheet = new CSSStyleSheet();
145
+
146
+ emit(sheet);
147
+
148
+ expect(shadowRoot.adoptedStyleSheets).not.toContain(sheet);
149
+ });
150
+ });
151
+ });
152
+ });
package/src/globals.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/naming-convention */
2
+ // eslint-disable-next-line spaced-comment
3
+ /// <reference types="vite/client" /> — provides types for import.meta.env
2
4
  import { ExposedDependencies } from '@servicetitan/startup-utils';
3
5
  import { ExposedInstanceDependencies } from './common';
4
6
 
@@ -8,6 +10,13 @@ declare global {
8
10
  export const WEB_COMPONENT_NAME: string | undefined;
9
11
  }
10
12
 
13
+ /*
14
+ * Safely access a value that may not exist. Returns undefined instead of
15
+ * throwing a ReferenceError. Used below to try global constants first
16
+ * (set by webpack DefinePlugin), then fall back to import.meta.env
17
+ * (set by Vite's define config). One of these will always be defined
18
+ * depending on which bundler built the code.
19
+ */
11
20
  function get<T>(fetch: () => T) {
12
21
  try {
13
22
  return fetch();
@@ -16,17 +25,28 @@ function get<T>(fetch: () => T) {
16
25
  }
17
26
  }
18
27
 
19
- const _EXPOSED_DEPENDENCIES = get(() => {
20
- return EXPOSED_DEPENDENCIES;
21
- });
28
+ const _EXPOSED_DEPENDENCIES =
29
+ get(() => {
30
+ return EXPOSED_DEPENDENCIES;
31
+ }) ??
32
+ get(() => {
33
+ return import.meta.env.EXPOSED_DEPENDENCIES;
34
+ });
22
35
 
23
- const _WEB_COMPONENT_NAME = get(() => {
24
- return WEB_COMPONENT_NAME;
25
- });
26
-
27
- const _EXPOSED_INSTANCE_DEPENDENCIES = get(() => {
28
- return EXPOSED_INSTANCE_DEPENDENCIES;
29
- });
36
+ const _WEB_COMPONENT_NAME =
37
+ get(() => {
38
+ return WEB_COMPONENT_NAME;
39
+ }) ??
40
+ get(() => {
41
+ return import.meta.env.WEB_COMPONENT_NAME;
42
+ });
43
+ const _EXPOSED_INSTANCE_DEPENDENCIES =
44
+ get(() => {
45
+ return EXPOSED_INSTANCE_DEPENDENCIES;
46
+ }) ??
47
+ get(() => {
48
+ return import.meta.env.EXPOSED_INSTANCE_DEPENDENCIES;
49
+ });
30
50
 
31
51
  export {
32
52
  _EXPOSED_DEPENDENCIES as EXPOSED_DEPENDENCIES,
package/src/index.ts CHANGED
@@ -10,3 +10,4 @@ export * from './register-headless';
10
10
  export * from './use-prefetch';
11
11
 
12
12
  export type { JSON, JSONPrimitive, Serializable } from './types';
13
+ export { withCssInjector } from './with-css-injector';
@@ -2,6 +2,7 @@ import type { LDService } from '@servicetitan/launchdarkly-service';
2
2
  import type { Log } from '@servicetitan/log-service';
3
3
  import type { Entries, IWebComponent } from './common';
4
4
  import type { EventBus } from './event-bus';
5
+ import { WEB_COMPONENT_NAME } from './globals';
5
6
  import { Serializable } from './types';
6
7
 
7
8
  type HeadlessCallbackArgs<TMfeData, TEventBus extends EventBus = EventBus> = Pick<