@servicetitan/web-components 36.1.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 (46) 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/utils/get-bundle-info.d.ts.map +1 -1
  19. package/dist/utils/get-bundle-info.js +10 -4
  20. package/dist/utils/get-bundle-info.js.map +1 -1
  21. package/dist/utils/get-entry-points.d.ts +1 -0
  22. package/dist/utils/get-entry-points.d.ts.map +1 -1
  23. package/dist/utils/get-entry-points.js.map +1 -1
  24. package/dist/utils/get-ld-service-token.d.ts +2 -3
  25. package/dist/utils/get-ld-service-token.d.ts.map +1 -1
  26. package/dist/utils/get-ld-service-token.js +2 -7
  27. package/dist/utils/get-ld-service-token.js.map +1 -1
  28. package/dist/with-css-injector.d.ts +3 -0
  29. package/dist/with-css-injector.d.ts.map +1 -0
  30. package/dist/with-css-injector.js +52 -0
  31. package/dist/with-css-injector.js.map +1 -0
  32. package/package.json +20 -11
  33. package/src/__mocks__/register-anvil2-harness.tsx +49 -0
  34. package/src/__mocks__/test-component.tsx +9 -2
  35. package/src/__tests__/register-anvil2-optional.test.tsx +32 -0
  36. package/src/__tests__/register-anvil2.test.tsx +11 -64
  37. package/src/__tests__/with-css-injector.test.tsx +152 -0
  38. package/src/globals.ts +30 -10
  39. package/src/index.ts +1 -0
  40. package/src/register-headless.ts +1 -0
  41. package/src/register.tsx +48 -42
  42. package/src/render.ts +3 -14
  43. package/src/utils/get-bundle-info.ts +11 -5
  44. package/src/utils/get-entry-points.ts +1 -0
  45. package/src/utils/get-ld-service-token.ts +2 -10
  46. package/src/with-css-injector.tsx +45 -0
@@ -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.1.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,14 +10,24 @@
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.1.0",
20
- "@servicetitan/startup-utils": "36.1.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"
@@ -25,9 +35,9 @@
25
35
  "devDependencies": {
26
36
  "@servicetitan/anvil2": "^2.8.0",
27
37
  "@servicetitan/design-system": "~14.5.3",
28
- "@servicetitan/launchdarkly-service": "36.1.0",
29
- "@servicetitan/log-service": "36.1.0",
30
- "@servicetitan/react-ioc": "36.1.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.1.0",
49
- "@servicetitan/log-service": "36.1.0",
50
- "@servicetitan/react-ioc": "36.1.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": "b7b5cade33151c4d183faf498ddc1321694b7b2b"
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 (
@@ -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<
package/src/register.tsx CHANGED
@@ -1,8 +1,10 @@
1
+ import * as Anvil2 from '@servicetitan/anvil2';
2
+ import * as Anvil1 from '@servicetitan/design-system';
1
3
  import { type LDService } from '@servicetitan/launchdarkly-service';
2
4
  import { Log } from '@servicetitan/log-service';
3
5
  import { Provider } from '@servicetitan/react-ioc';
4
6
  import { ExposedDependencies } from '@servicetitan/startup-utils';
5
- import { ComponentType, FC, ReactElement, useEffect } from 'react';
7
+ import { ComponentType, FC, PropsWithChildren, ReactElement, useCallback, useEffect } from 'react';
6
8
  import {
7
9
  BASENAME_TOKEN,
8
10
  Entries,
@@ -40,6 +42,43 @@ export interface RegisterOptions {
40
42
  sharedDependenciesNames?: string[];
41
43
  }
42
44
 
45
+ const Anvil1Providers: FC<{ root: ShadowRoot; portal: ShadowRoot } & PropsWithChildren> = ({
46
+ root,
47
+ portal,
48
+ children,
49
+ }) => {
50
+ const getTablePopupProps = useCallback(
51
+ (props: object) => ({
52
+ ...props,
53
+ appendTo: root as unknown as HTMLElement,
54
+ }),
55
+ [root]
56
+ );
57
+
58
+ if (Anvil1.DefaultPortalContext === undefined || Anvil1.TablePopupPropsContext === undefined) {
59
+ return children;
60
+ }
61
+
62
+ return (
63
+ <Anvil1.DefaultPortalContext.Provider value={portal as unknown as HTMLElement}>
64
+ <Anvil1.TablePopupPropsContext.Provider value={getTablePopupProps}>
65
+ {children}
66
+ </Anvil1.TablePopupPropsContext.Provider>
67
+ </Anvil1.DefaultPortalContext.Provider>
68
+ );
69
+ };
70
+
71
+ const Anvil2Providers: FC<{ portalAnvilContainer: HTMLDivElement } & PropsWithChildren> = ({
72
+ portalAnvilContainer,
73
+ children,
74
+ }) => {
75
+ if (Anvil2.PortalProvider === undefined) {
76
+ return children;
77
+ }
78
+
79
+ return <Anvil2.PortalProvider root={portalAnvilContainer}>{children}</Anvil2.PortalProvider>;
80
+ };
81
+
43
82
  export function register(Component: ComponentType, light: boolean, options?: RegisterOptions) {
44
83
  class WebComponent extends HTMLElement implements IWebComponent {
45
84
  private root?: ShadowRoot;
@@ -165,47 +204,14 @@ export function register(Component: ComponentType, light: boolean, options?: Reg
165
204
  return elements;
166
205
  };
167
206
 
168
- private withAnvil1Providers(children: ReactElement): ReactElement {
169
- try {
170
- /**
171
- * Note, the webpack.IgnorePlugin must be configured to ignore this dependency.
172
- * @see {@link file://./../../startup/src/webpack/configs/plugins/ignore-plugin/ignore-plugin.ts}
173
- */
174
- const { DefaultPortalContext, TablePopupPropsContext } =
175
- require('@servicetitan/design-system') as typeof import('@servicetitan/design-system');
176
- return (
177
- <DefaultPortalContext.Provider value={this.portal as unknown as HTMLElement}>
178
- <TablePopupPropsContext.Provider
179
- value={props => ({
180
- ...props,
181
- appendTo: this.root as unknown as HTMLElement,
182
- })}
183
- >
184
- {children}
185
- </TablePopupPropsContext.Provider>
186
- </DefaultPortalContext.Provider>
187
- );
188
- } catch {
189
- return children;
190
- }
191
- }
192
-
193
- private withAnvil2Providers(children: ReactElement): ReactElement {
194
- try {
195
- /**
196
- * Note, the webpack.IgnorePlugin must be configured to ignore this dependency.
197
- * @see {@link file://./../../startup/src/webpack/configs/plugins/ignore-plugin/ignore-plugin.ts}
198
- */
199
- const { PortalProvider } =
200
- require('@servicetitan/anvil2') as typeof import('@servicetitan/anvil2');
201
- return <PortalProvider root={this.portalAnvilContainer}>{children}</PortalProvider>;
202
- } catch {
203
- return children;
204
- }
205
- }
206
-
207
- private withProviders(children: ReactElement): ReactElement {
208
- return this.withAnvil2Providers(this.withAnvil1Providers(children));
207
+ private withProviders(children: ReactElement) {
208
+ return (
209
+ <Anvil2Providers portalAnvilContainer={this.portalAnvilContainer!}>
210
+ <Anvil1Providers root={this.root!} portal={this.portal!}>
211
+ {children}
212
+ </Anvil1Providers>
213
+ </Anvil2Providers>
214
+ );
209
215
  }
210
216
 
211
217
  private render = () => {
package/src/render.ts CHANGED
@@ -1,18 +1,7 @@
1
1
  /* eslint-disable react/no-deprecated */
2
2
  import { ReactElement } from 'react';
3
3
  import ReactDOM from 'react-dom';
4
- import type ReactDOMClient from 'react-dom/client';
5
-
6
- let createRoot: typeof ReactDOMClient.createRoot;
7
- try {
8
- /**
9
- * Note, the webpack.IgnorePlugin must be configured to ignore this dependency.
10
- * @see {@link file://./../../startup/src/webpack/configs/plugins/ignore-plugin/ignore-plugin.ts}
11
- */
12
- createRoot = require('react-dom/client').createRoot;
13
- } catch {
14
- // ignore
15
- }
4
+ import * as ReactDOMClient from 'react-dom/client';
16
5
 
17
6
  interface RenderOptions {
18
7
  legacyRoot?: boolean;
@@ -28,11 +17,11 @@ export function render(
28
17
  container: Element | DocumentFragment,
29
18
  options?: RenderOptions
30
19
  ): RenderResult {
31
- if (typeof createRoot !== 'function' || options?.legacyRoot) {
20
+ if (typeof ReactDOMClient.createRoot !== 'function' || options?.legacyRoot) {
32
21
  return renderLegacyRoot(element, container);
33
22
  }
34
23
 
35
- const root = createRoot(container);
24
+ const root = ReactDOMClient.createRoot(container);
36
25
  root.render(element);
37
26
 
38
27
  return {