@servicetitan/web-components 36.3.0 → 36.4.0

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 (52) hide show
  1. package/dist/css-injector/index.d.ts +3 -0
  2. package/dist/css-injector/index.d.ts.map +1 -0
  3. package/dist/css-injector/index.js +4 -0
  4. package/dist/css-injector/index.js.map +1 -0
  5. package/dist/css-injector/mfe-style-registry.d.ts +14 -0
  6. package/dist/css-injector/mfe-style-registry.d.ts.map +1 -0
  7. package/dist/css-injector/mfe-style-registry.js +63 -0
  8. package/dist/css-injector/mfe-style-registry.js.map +1 -0
  9. package/dist/css-injector/with-css-injector.d.ts +6 -0
  10. package/dist/css-injector/with-css-injector.d.ts.map +1 -0
  11. package/dist/css-injector/with-css-injector.js +38 -0
  12. package/dist/css-injector/with-css-injector.js.map +1 -0
  13. package/dist/globals.d.ts +3 -3
  14. package/dist/globals.d.ts.map +1 -1
  15. package/dist/globals.js +19 -4
  16. package/dist/globals.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/register-headless.d.ts.map +1 -1
  22. package/dist/register-headless.js +1 -0
  23. package/dist/register-headless.js.map +1 -1
  24. package/dist/register.d.ts.map +1 -1
  25. package/dist/register.js +43 -37
  26. package/dist/register.js.map +1 -1
  27. package/dist/render.d.ts.map +1 -1
  28. package/dist/render.js +2 -2
  29. package/dist/render.js.map +1 -1
  30. package/dist/utils/get-bundle-info.d.ts.map +1 -1
  31. package/dist/utils/get-bundle-info.js +11 -4
  32. package/dist/utils/get-bundle-info.js.map +1 -1
  33. package/dist/utils/get-entry-points.d.ts +1 -0
  34. package/dist/utils/get-entry-points.d.ts.map +1 -1
  35. package/dist/utils/get-entry-points.js.map +1 -1
  36. package/package.json +23 -12
  37. package/src/__mocks__/register-anvil2-harness.tsx +49 -0
  38. package/src/__mocks__/test-component.tsx +9 -2
  39. package/src/__tests__/loader.test.tsx +32 -0
  40. package/src/__tests__/register-anvil2.test.tsx +21 -56
  41. package/src/css-injector/__tests__/mfe-style-registry.test.ts +163 -0
  42. package/src/css-injector/__tests__/with-css-injector.test.tsx +128 -0
  43. package/src/css-injector/index.ts +2 -0
  44. package/src/css-injector/mfe-style-registry.ts +48 -0
  45. package/src/css-injector/with-css-injector.tsx +40 -0
  46. package/src/globals.ts +30 -10
  47. package/src/index.ts +1 -0
  48. package/src/register-headless.ts +1 -0
  49. package/src/register.tsx +54 -43
  50. package/src/render.ts +2 -3
  51. package/src/utils/get-bundle-info.ts +12 -5
  52. package/src/utils/get-entry-points.ts +1 -0
@@ -0,0 +1,128 @@
1
+ import { render } from '@testing-library/react';
2
+ import { FC } from 'react';
3
+ import { useMFEMetadataContext } from '../../contexts/mfe-metadata-context';
4
+ import { MfeStyleRegistry } from '../mfe-style-registry';
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
+ jest.mock('../mfe-style-registry');
13
+
14
+ describe(`[web-components] ${withCssInjector.name}`, () => {
15
+ const innerTestId = 'inner';
16
+ const innerLabel = 'foo';
17
+ const InnerComponent: FC<{ label: string }> = ({ label }) => (
18
+ <div data-testid={innerTestId}>{label}</div>
19
+ );
20
+ const WrappedComponent = withCssInjector(InnerComponent);
21
+
22
+ let registry: jest.Mocked<MfeStyleRegistry>;
23
+ let shadowRoot: ShadowRoot;
24
+ let portalShadowRoot: ShadowRoot;
25
+
26
+ beforeEach(() => {
27
+ jest.clearAllMocks();
28
+ delete globalThis.__mfeSelfHosted__;
29
+ registry = {
30
+ observeRoot: jest.fn(),
31
+ unobserveRoot: jest.fn(),
32
+ } as unknown as jest.Mocked<MfeStyleRegistry>;
33
+ jest.mocked(MfeStyleRegistry.for).mockReturnValue(registry);
34
+ shadowRoot = document.createElement('div').attachShadow({ mode: 'open' });
35
+ portalShadowRoot = document.createElement('span').attachShadow({ mode: 'open' });
36
+ jest.mocked(useMFEMetadataContext).mockImplementation(() => ({
37
+ shadowRoot,
38
+ portalShadowRoot,
39
+ }));
40
+ });
41
+
42
+ const subject = () => render(<WrappedComponent label={innerLabel} />);
43
+
44
+ test('renders the wrapped component with its props', () => {
45
+ const { getByTestId } = subject();
46
+
47
+ expect(getByTestId(innerTestId).textContent).toBe(innerLabel);
48
+ });
49
+
50
+ test('retrieves the registry for the component', () => {
51
+ subject();
52
+
53
+ expect(MfeStyleRegistry.for).toHaveBeenCalledWith('test-mfe');
54
+ });
55
+
56
+ test('observes shadow root', () => {
57
+ subject();
58
+
59
+ expect(registry.observeRoot).toHaveBeenCalledWith(shadowRoot);
60
+ });
61
+
62
+ test('observes portal shadow root', () => {
63
+ subject();
64
+
65
+ expect(registry.observeRoot).toHaveBeenCalledWith(portalShadowRoot);
66
+ });
67
+
68
+ describe('when self-hosted', () => {
69
+ beforeEach(() => {
70
+ globalThis.__mfeSelfHosted__ = true;
71
+ });
72
+
73
+ test('observes document', () => {
74
+ subject();
75
+
76
+ expect(registry.observeRoot).toHaveBeenCalledWith(document);
77
+ });
78
+
79
+ test('unobserves document on unmount', () => {
80
+ const { unmount } = subject();
81
+
82
+ unmount();
83
+
84
+ expect(registry.unobserveRoot).toHaveBeenCalledWith(document);
85
+ });
86
+ });
87
+
88
+ describe('when unmounted', () => {
89
+ test('unobserves shadow root', () => {
90
+ const { unmount } = subject();
91
+
92
+ unmount();
93
+
94
+ expect(registry.unobserveRoot).toHaveBeenCalledWith(shadowRoot);
95
+ });
96
+
97
+ test('unobserves portal shadow root', () => {
98
+ const { unmount } = subject();
99
+
100
+ unmount();
101
+
102
+ expect(registry.unobserveRoot).toHaveBeenCalledWith(portalShadowRoot);
103
+ });
104
+ });
105
+
106
+ describe('in production', () => {
107
+ const originalEnv = process.env.NODE_ENV;
108
+
109
+ beforeEach(() => {
110
+ process.env.NODE_ENV = 'production';
111
+ });
112
+
113
+ afterEach(() => {
114
+ process.env.NODE_ENV = originalEnv;
115
+ });
116
+
117
+ test('returns Component directly', () => {
118
+ expect.assertions(1);
119
+
120
+ jest.isolateModules(() => {
121
+ const { withCssInjector: freshWithCssInjector } = require('../with-css-injector');
122
+ const Dummy: FC = () => <div />;
123
+
124
+ expect(freshWithCssInjector(Dummy)).toBe(Dummy);
125
+ });
126
+ });
127
+ });
128
+ });
@@ -0,0 +1,2 @@
1
+ export { MfeStyleRegistry } from './mfe-style-registry';
2
+ export { withCssInjector } from './with-css-injector';
@@ -0,0 +1,48 @@
1
+ declare global {
2
+ // eslint-disable-next-line @typescript-eslint/naming-convention
3
+ var __mfeStyles__: Record<string, MfeStyleRegistry> | undefined;
4
+ }
5
+
6
+ type StyleRoot = ShadowRoot | Document;
7
+
8
+ export class MfeStyleRegistry {
9
+ readonly sheets = new Map<string, CSSStyleSheet>();
10
+ readonly roots = new Set<StyleRoot>();
11
+
12
+ // Per-component state stored on globalThis.__mfeStyles__ so it survives Vite HMR module reloads.
13
+ static for(componentName: string): MfeStyleRegistry {
14
+ globalThis.__mfeStyles__ ??= {};
15
+ globalThis.__mfeStyles__[componentName] ??= new MfeStyleRegistry();
16
+ return globalThis.__mfeStyles__[componentName];
17
+ }
18
+
19
+ injectSheet(sheetId: string, css: string) {
20
+ let sheet = this.sheets.get(sheetId);
21
+ if (sheet) {
22
+ sheet.replaceSync(css);
23
+ } else {
24
+ sheet = new CSSStyleSheet();
25
+ sheet.replaceSync(css);
26
+ this.sheets.set(sheetId, sheet);
27
+ for (const root of this.roots) {
28
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
29
+ }
30
+ }
31
+ }
32
+
33
+ observeRoot(root: StyleRoot) {
34
+ if (this.roots.has(root)) {
35
+ return;
36
+ }
37
+ this.roots.add(root);
38
+ root.adoptedStyleSheets ??= [];
39
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, ...this.sheets.values()];
40
+ }
41
+
42
+ unobserveRoot(root: StyleRoot) {
43
+ this.roots.delete(root);
44
+ const sheets = new Set(this.sheets.values());
45
+ root.adoptedStyleSheets ??= [];
46
+ root.adoptedStyleSheets = root.adoptedStyleSheets.filter(s => !sheets.has(s));
47
+ }
48
+ }
@@ -0,0 +1,40 @@
1
+ import { ComponentType, useLayoutEffect } from 'react';
2
+ import { useMFEMetadataContext } from '../contexts/mfe-metadata-context';
3
+ import { WEB_COMPONENT_NAME } from '../globals';
4
+ import { MfeStyleRegistry } from './mfe-style-registry';
5
+
6
+ declare global {
7
+ // eslint-disable-next-line @typescript-eslint/naming-convention
8
+ var __mfeSelfHosted__: boolean | undefined;
9
+ }
10
+
11
+ /*
12
+ * HOC wrapping the MFE's root component in register(). Adopts sheets from the registry into
13
+ * the MFE's shadow roots. Only active in development; eliminated by Vite in production builds.
14
+ */
15
+ export function withCssInjector<P extends object>(Component: ComponentType<P>): ComponentType<P> {
16
+ if (process.env.NODE_ENV === 'production') {
17
+ return Component;
18
+ }
19
+
20
+ return function CssInjectorWrapper(props: P) {
21
+ const { shadowRoot, portalShadowRoot } = useMFEMetadataContext();
22
+
23
+ useLayoutEffect(() => {
24
+ const registry = MfeStyleRegistry.for(WEB_COMPONENT_NAME);
25
+ const roots = [
26
+ shadowRoot,
27
+ portalShadowRoot,
28
+ ...(globalThis.__mfeSelfHosted__ ? [document] : []),
29
+ ];
30
+
31
+ roots.forEach(root => registry.observeRoot(root));
32
+
33
+ return () => {
34
+ roots.forEach(root => registry.unobserveRoot(root));
35
+ };
36
+ }, [shadowRoot, portalShadowRoot]);
37
+
38
+ return <Component {...props} />;
39
+ };
40
+ }
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
@@ -1,5 +1,6 @@
1
1
  export * from './common';
2
2
  export * from './contexts';
3
+ export * from './css-injector';
3
4
  export * from './event-bus';
4
5
  export * from './get-value-for-environment';
5
6
  export * from './headless-loader';
@@ -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
@@ -2,7 +2,7 @@ import { type LDService } from '@servicetitan/launchdarkly-service';
2
2
  import { Log } from '@servicetitan/log-service';
3
3
  import { Provider } from '@servicetitan/react-ioc';
4
4
  import { ExposedDependencies } from '@servicetitan/startup-utils';
5
- import { ComponentType, FC, ReactElement, useEffect } from 'react';
5
+ import { ComponentType, FC, PropsWithChildren, ReactElement, useCallback, useEffect } from 'react';
6
6
  import {
7
7
  BASENAME_TOKEN,
8
8
  Entries,
@@ -15,6 +15,7 @@ import {
15
15
  } from './common';
16
16
  import { MFEDataContext } from './contexts/mfe-data-context';
17
17
  import { MFEMetadataContext } from './contexts/mfe-metadata-context';
18
+ import { withCssInjector } from './css-injector';
18
19
  import { EVENT_BUS_TOKEN, EventBus } from './event-bus';
19
20
  import { WEB_COMPONENT_NAME } from './globals';
20
21
  import { HistoryManager } from './history-manager';
@@ -40,7 +41,50 @@ export interface RegisterOptions {
40
41
  sharedDependenciesNames?: string[];
41
42
  }
42
43
 
44
+ const Anvil1Providers: FC<{ root: ShadowRoot; portal: ShadowRoot } & PropsWithChildren> = ({
45
+ root,
46
+ portal,
47
+ children,
48
+ }) => {
49
+ const getTablePopupProps = useCallback(
50
+ (props: object) => ({
51
+ ...props,
52
+ appendTo: root as unknown as HTMLElement,
53
+ }),
54
+ [root]
55
+ );
56
+
57
+ try {
58
+ const { DefaultPortalContext, TablePopupPropsContext } =
59
+ require('@servicetitan/design-system') as typeof import('@servicetitan/design-system');
60
+ return (
61
+ <DefaultPortalContext.Provider value={portal as unknown as HTMLElement}>
62
+ <TablePopupPropsContext.Provider value={getTablePopupProps}>
63
+ {children}
64
+ </TablePopupPropsContext.Provider>
65
+ </DefaultPortalContext.Provider>
66
+ );
67
+ } catch {
68
+ return children;
69
+ }
70
+ };
71
+
72
+ const Anvil2Providers: FC<{ portalAnvilContainer: HTMLDivElement } & PropsWithChildren> = ({
73
+ portalAnvilContainer,
74
+ children,
75
+ }) => {
76
+ try {
77
+ const { PortalProvider } =
78
+ require('@servicetitan/anvil2') as typeof import('@servicetitan/anvil2');
79
+ return <PortalProvider root={portalAnvilContainer}>{children}</PortalProvider>;
80
+ } catch {
81
+ return children;
82
+ }
83
+ };
84
+
43
85
  export function register(Component: ComponentType, light: boolean, options?: RegisterOptions) {
86
+ const WrappedComponent = withCssInjector(Component);
87
+
44
88
  class WebComponent extends HTMLElement implements IWebComponent {
45
89
  private root?: ShadowRoot;
46
90
  private portal?: ShadowRoot;
@@ -165,47 +209,14 @@ export function register(Component: ComponentType, light: boolean, options?: Reg
165
209
  return elements;
166
210
  };
167
211
 
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));
212
+ private withProviders(children: ReactElement) {
213
+ return (
214
+ <Anvil2Providers portalAnvilContainer={this.portalAnvilContainer!}>
215
+ <Anvil1Providers root={this.root!} portal={this.portal!}>
216
+ {children}
217
+ </Anvil1Providers>
218
+ </Anvil2Providers>
219
+ );
209
220
  }
210
221
 
211
222
  private render = () => {
@@ -264,7 +275,7 @@ export function register(Component: ComponentType, light: boolean, options?: Reg
264
275
  >
265
276
  <MFEDataContext.Provider value={{ ...mfeData }}>
266
277
  <MFEMetadataContext.Provider value={metadata}>
267
- {this.withProviders(<Component {...mfeData} />)}
278
+ {this.withProviders(<WrappedComponent {...mfeData} />)}
268
279
  {this.onReady && <RenderCallback callback={this.onReady} />}
269
280
  </MFEMetadataContext.Provider>
270
281
  </MFEDataContext.Provider>
package/src/render.ts CHANGED
@@ -6,14 +6,13 @@ import type ReactDOMClient from 'react-dom/client';
6
6
  let createRoot: typeof ReactDOMClient.createRoot;
7
7
  try {
8
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}
9
+ * Note, bundlers must be configured to ignore this dependency.
10
+ * @see {@link file://./../../startup/src/core/check-resource/is-managed-react-dom-client-dependency.ts}
11
11
  */
12
12
  createRoot = require('react-dom/client').createRoot;
13
13
  } catch {
14
14
  // ignore
15
15
  }
16
-
17
16
  interface RenderOptions {
18
17
  legacyRoot?: boolean;
19
18
  }
@@ -78,14 +78,13 @@ export async function getBundleInfo({
78
78
  resolvedBundleType = embed ? 'light' : 'full';
79
79
  }
80
80
 
81
- const withBaseUrl = (path: string) =>
82
- `${exactPackageUrl}/dist/bundle/${resolvedBundleType}/${path}`;
81
+ const bundleBaseUrl = `${exactPackageUrl}/dist/bundle/${resolvedBundleType}`;
83
82
 
84
83
  let entrypoints: EntryPoints;
85
84
  if (metadata.entrypoints?.[resolvedBundleType]) {
86
85
  entrypoints = metadata.entrypoints[resolvedBundleType];
87
86
  } else {
88
- entrypoints = await getEntrypoints(withBaseUrl('entrypoints.json'), requestCache);
87
+ entrypoints = await getEntrypoints(`${bundleBaseUrl}/entrypoints.json`, requestCache);
89
88
  }
90
89
 
91
90
  const disposer = () => {
@@ -97,9 +96,17 @@ export async function getBundleInfo({
97
96
  }
98
97
  };
99
98
 
99
+ let baseUrl = bundleBaseUrl;
100
+ // Per-MFE dev-server port only happens during local development
101
+ if (process.env.NODE_ENV !== 'production' && entrypoints.port) {
102
+ const url = new URL(baseUrl);
103
+ url.port = String(entrypoints.port);
104
+ baseUrl = url.toString();
105
+ }
106
+
100
107
  const urls: EntryPoints = {
101
- css: entrypoints.css.map(withBaseUrl),
102
- js: entrypoints.js.map(withBaseUrl),
108
+ css: entrypoints.css.map(path => `${baseUrl}/${path}`),
109
+ js: entrypoints.js.map(path => `${baseUrl}/${path}`),
103
110
  };
104
111
 
105
112
  const elementName = resolvedBundleType === 'headless' ? `headless-${name}` : name;
@@ -4,6 +4,7 @@ export interface EntryPoints {
4
4
  css: string[];
5
5
  js: string[];
6
6
  module?: true;
7
+ port?: number;
7
8
  }
8
9
 
9
10
  export async function getEntrypoints(