@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
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 {
package/src/types.ts CHANGED
@@ -18,5 +18,5 @@ export interface Metadata {
18
18
  bundledWith?: Record<string, string | undefined>;
19
19
  sharedDependencies: Record<string, string>;
20
20
  dependencies?: Record<string, string>;
21
- entrypoints?: Record<string, { css: string[]; js: string[] }>;
21
+ entrypoints?: Record<string, { css: string[]; js: string[]; module?: true }>;
22
22
  }
@@ -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,10 +96,17 @@ export async function getBundleInfo({
97
96
  }
98
97
  };
99
98
 
100
- const urls = Object.entries(entrypoints).reduce(
101
- (result, [key, value]) => ({ ...result, [key]: value.map(withBaseUrl) }),
102
- {} as EntryPoints
103
- );
99
+ let baseUrl = bundleBaseUrl;
100
+ if (entrypoints.port) {
101
+ const url = new URL(baseUrl);
102
+ url.port = String(entrypoints.port);
103
+ baseUrl = url.toString();
104
+ }
105
+
106
+ const urls: EntryPoints = {
107
+ css: entrypoints.css.map(path => `${baseUrl}/${path}`),
108
+ js: entrypoints.js.map(path => `${baseUrl}/${path}`),
109
+ };
104
110
 
105
111
  const elementName = resolvedBundleType === 'headless' ? `headless-${name}` : name;
106
112
 
@@ -119,5 +125,6 @@ export async function getBundleInfo({
119
125
  urls,
120
126
  WebComponent: elementName,
121
127
  version: metadata.version,
128
+ module: entrypoints.module,
122
129
  };
123
130
  }
@@ -3,6 +3,8 @@ import { getJson } from '../common';
3
3
  export interface EntryPoints {
4
4
  css: string[];
5
5
  js: string[];
6
+ module?: true;
7
+ port?: number;
6
8
  }
7
9
 
8
10
  export async function getEntrypoints(
@@ -1,13 +1,5 @@
1
- import { type LDService } from '@servicetitan/launchdarkly-service';
2
- import { SymbolToken } from '@servicetitan/react-ioc';
3
-
4
- let token: SymbolToken<LDService>;
5
- try {
6
- token = require('@servicetitan/launchdarkly-service').LD_SERVICE_TOKEN;
7
- } catch {
8
- // ignore
9
- }
1
+ import * as LDService from '@servicetitan/launchdarkly-service';
10
2
 
11
3
  export function getLDServiceToken() {
12
- return token;
4
+ return LDService.LD_SERVICE_TOKEN;
13
5
  }
@@ -1,6 +1,6 @@
1
1
  import { BundleInfo } from './get-bundle-info';
2
2
 
3
- export function loadScripts({ urls, WebComponent }: BundleInfo) {
3
+ export function loadScripts({ urls, WebComponent, module }: BundleInfo) {
4
4
  return Promise.all(
5
5
  urls.js.map(url => {
6
6
  const script = Array.from(document.scripts).find(({ src }) => src === url);
@@ -8,13 +8,18 @@ export function loadScripts({ urls, WebComponent }: BundleInfo) {
8
8
  return new Promise<void>(resolve => {
9
9
  const script = document.createElement('script');
10
10
 
11
+ if (module) {
12
+ script.type = 'module';
13
+ } else {
14
+ script.async = true;
15
+ }
16
+
11
17
  script.crossOrigin = 'anonymous';
12
18
  script.onload = () => {
13
19
  script.dataset.webComponent = WebComponent;
14
20
  script.onload = null;
15
21
  resolve();
16
22
  };
17
- script.async = true;
18
23
  script.src = url;
19
24
 
20
25
  document.body.append(script);
@@ -0,0 +1,45 @@
1
+ import { ComponentType, useEffect } from 'react';
2
+ import { useMFEMetadataContext } from './contexts/mfe-metadata-context';
3
+ import { WEB_COMPONENT_NAME } from './globals';
4
+
5
+ export function withCssInjector<P extends object>(Component: ComponentType<P>): ComponentType<P> {
6
+ return function CssInjectorWrapper(props: P) {
7
+ const { shadowRoot, portalShadowRoot } = useMFEMetadataContext();
8
+
9
+ useEffect(() => {
10
+ window.__mfeStyles__ ??= {};
11
+ const mfeStyles = window.__mfeStyles__;
12
+ mfeStyles[WEB_COMPONENT_NAME] ??= Object.assign(new EventTarget(), {
13
+ sheets: new Map<string, CSSStyleSheet>(),
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
+ */
20
+ const roots = [
21
+ shadowRoot,
22
+ portalShadowRoot,
23
+ ...(window.__mfeSelfHosted__ ? [document] : []),
24
+ ];
25
+ function adoptSheet(sheet: CSSStyleSheet) {
26
+ for (const root of roots) {
27
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
28
+ }
29
+ }
30
+
31
+ for (const sheet of emitter.sheets.values()) {
32
+ adoptSheet(sheet);
33
+ }
34
+
35
+ const handleNewSheet = ({ detail: sheetId }: CustomEvent<string>) => {
36
+ adoptSheet(emitter.sheets.get(sheetId)!);
37
+ };
38
+
39
+ emitter.addEventListener('new-sheet', handleNewSheet);
40
+ return () => emitter.removeEventListener('new-sheet', handleNewSheet);
41
+ }, [shadowRoot, portalShadowRoot]);
42
+
43
+ return <Component {...props} />;
44
+ };
45
+ }