@financial-times/custom-code-component 2.0.1-beta.9 → 2.0.3

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.
@@ -13,7 +13,7 @@ import {
13
13
  CCCRenderError,
14
14
  CCCTimeoutError,
15
15
  } from "./errors";
16
- import { CCCConnectedEvent } from "./events";
16
+ import { CCCConnectedEvent, CCCReadyEvent, CCCViewportEvent } from "./events";
17
17
  import { ComponentPath } from "./path";
18
18
  import { kebabize } from "./util";
19
19
  import { assignTestURL, useComponentTestEnv } from "./environment";
@@ -35,10 +35,24 @@ export class FTCustomCodeComponent extends HTMLElement {
35
35
 
36
36
  source?: string;
37
37
  tracking?: Tracking;
38
- component?: ComponentPath;
38
+ testUrl?: URL;
39
+ component = new ComponentPath();
39
40
 
40
41
  log: Logger;
41
42
 
43
+ observer = new IntersectionObserver((entries) => {
44
+ entries.forEach((entry) => {
45
+ this.dispatchEvent(
46
+ new CCCViewportEvent({
47
+ component: this.component,
48
+ source: this.source,
49
+ intersecting: entry.isIntersecting,
50
+ entry,
51
+ })
52
+ );
53
+ });
54
+ });
55
+
42
56
  constructor() {
43
57
  super();
44
58
  this.log = new Logger({
@@ -76,19 +90,40 @@ export class FTCustomCodeComponent extends HTMLElement {
76
90
  }
77
91
 
78
92
  emitError(error: Error) {
79
- this.dispatchEvent(new ErrorEvent("ccc:error", {
80
- bubbles: true,
81
- cancelable: false,
82
- composed: true,
83
- error,
84
- message: error.message,
85
- }));
93
+ if (error instanceof CCCError) {
94
+ this.dispatchEvent(
95
+ new ErrorEvent("ccc:error", {
96
+ bubbles: true,
97
+ cancelable: false,
98
+ composed: true,
99
+ error,
100
+ message: error.message,
101
+ })
102
+ );
103
+ } else {
104
+ // Wrap original error with generic CCC error class
105
+ const wrappedError = new CCCError(error.message, {
106
+ component: this.component,
107
+ error: error,
108
+ });
109
+
110
+ this.dispatchEvent(
111
+ new ErrorEvent("ccc:error", {
112
+ bubbles: true,
113
+ cancelable: false,
114
+ composed: true,
115
+ error: wrappedError,
116
+ message: wrappedError.message,
117
+ })
118
+ );
119
+ }
86
120
  }
87
121
 
88
122
  disconnectedCallback() {
89
123
  const path = this.getAttribute("path");
90
124
  this.log.info(`<custom-code-component:${path}> disconnected`);
91
125
  if (typeof this.onunmount === "function") this.onunmount();
126
+ this.observer.disconnect();
92
127
  }
93
128
 
94
129
  channel = new MessageChannel();
@@ -100,11 +135,23 @@ export class FTCustomCodeComponent extends HTMLElement {
100
135
  async onready(app: Promise<void>) {
101
136
  try {
102
137
  await app;
138
+
139
+ this.dispatchEvent(
140
+ new CCCReadyEvent({
141
+ component: this.component,
142
+ source: this.source,
143
+ })
144
+ );
145
+
146
+ this.dataset.cccReady = "true";
147
+ delete this.dataset.cccError;
148
+
149
+ this.observer.observe(this);
103
150
  } catch (e) {
104
151
  if (e instanceof Error) {
105
152
  const renderError = new CCCRenderError(e.message, {
106
153
  error: e,
107
- component: this.component!,
154
+ component: this.component,
108
155
  });
109
156
  requestAnimationFrame(() => {
110
157
  this.emitError(renderError);
@@ -123,16 +170,6 @@ export class FTCustomCodeComponent extends HTMLElement {
123
170
  this.mode =
124
171
  this.getAttribute("shadow-open") == "false" ? "closed" : "open";
125
172
  if (this.component) {
126
- this.dispatchEvent(
127
- new CCCConnectedEvent({
128
- component: this.component,
129
- source: this.source,
130
- })
131
- );
132
-
133
- this.dataset.cccReady = "true";
134
- delete this.dataset.cccError;
135
-
136
173
  if (!this.app) {
137
174
  throw new Error("CCC mounted without App");
138
175
  }
@@ -141,6 +178,12 @@ export class FTCustomCodeComponent extends HTMLElement {
141
178
  const shadow =
142
179
  this.shadowRoot ?? this.attachShadow({ mode: this.mode });
143
180
 
181
+ this.dispatchEvent(
182
+ new CCCConnectedEvent({
183
+ component: this.component,
184
+ source: this.source,
185
+ })
186
+ );
144
187
  // Add global CCC styles if not already present in shadow DOM.
145
188
  if (
146
189
  !shadow.querySelector('link[href~="custom-code-component.css"]') &&
@@ -194,19 +237,22 @@ export class FTCustomCodeComponent extends HTMLElement {
194
237
  );
195
238
  }
196
239
 
197
- const { unmount, onmessage, ready } =
198
- this.app(
199
- shadow,
200
- {
201
- ...(data ?? extraProps),
202
- data: data ?? extraProps,
203
- port: this.channel.port2,
204
- tracking: this.tracking,
205
- prerendered: !!prerendered,
206
- children: this.children,
207
- },
208
- ssr
209
- ) || {};
240
+ const {
241
+ unmount,
242
+ onmessage,
243
+ ready = Promise.resolve(),
244
+ } = this.app(
245
+ shadow,
246
+ {
247
+ ...(data ?? extraProps),
248
+ data: data ?? extraProps,
249
+ port: this.channel.port2,
250
+ tracking: this.tracking,
251
+ prerendered: !!prerendered,
252
+ children: this.children,
253
+ },
254
+ ssr
255
+ ) || {};
210
256
 
211
257
  if (unmount) this.onunmount = unmount;
212
258
  if (onmessage) this.onmessage = onmessage;
@@ -240,23 +286,33 @@ export class FTCustomCodeComponent extends HTMLElement {
240
286
  this.dataset.cccError = kebabize(e.name.replace("CCC", ""));
241
287
 
242
288
  delete this.dataset.cccReady;
289
+
290
+ this.observer.disconnect();
243
291
  }
244
292
 
245
293
  async load() {
246
- if (!this.component) {
294
+ if (!this.component.isValid) {
247
295
  throw new Error("No path found");
248
296
  }
249
297
  const path = this.getAttribute("path");
250
298
  const componentVersionRange = this.getAttribute("version");
251
299
  const timeout = Number(this.getAttribute("load-timeout") || 10000);
252
300
  const testEnv = this.getAttribute("test-env");
253
- const testUrl = assignTestURL(testEnv);
254
- const isTestEnv = await useComponentTestEnv(this.component.name, testUrl);
301
+ this.testUrl = assignTestURL(testEnv);
302
+ const isTestEnv = await useComponentTestEnv(
303
+ this.component.name,
304
+ this.testUrl
305
+ );
255
306
 
256
- const id = this.getAttribute("id");
257
307
  // id querystring necessary to multiple allow components with the same source (same name and version number) to appear on the page correctly
308
+ const id = this.getAttribute("id");
309
+
310
+ if (isTestEnv && this.testUrl) {
311
+ this.injectViteScripts();
312
+ }
313
+
258
314
  this.source = isTestEnv
259
- ? `${testUrl?.origin}/src/${this.component.name}/index.jsx?id=${id}`
315
+ ? `${this.testUrl?.origin}/src/${this.component.name}/index.jsx?id=${id}`
260
316
  : `https://www.ft.com/__component/${this.component.org}/${this.component.repo}${
261
317
  componentVersionRange ? `@${componentVersionRange}` : "@latest"
262
318
  }/${this.component.name}/${this.component.name}.js?id=${id}`;
@@ -333,6 +389,28 @@ export class FTCustomCodeComponent extends HTMLElement {
333
389
  this.log.error(e);
334
390
  }
335
391
  };
392
+
393
+ injectViteScripts = () => {
394
+ if (document.querySelector('script[name="ccc-sdk-react-preamble"]')) return;
395
+
396
+ const preambleScript = document.createElement("script");
397
+ preambleScript.type = "module";
398
+ preambleScript.setAttribute("name", "ccc-sdk-react-preamble");
399
+
400
+ preambleScript.textContent = `
401
+ import RefreshRuntime from "${this.testUrl?.origin}/@react-refresh";
402
+ RefreshRuntime.injectIntoGlobalHook(window);
403
+ window.$RefreshReg$ = () => {};
404
+ window.$RefreshSig$ = () => (type) => type;
405
+ window.__vite_plugin_react_preamble_installed__ = true;
406
+ `.trim();
407
+ document.head.appendChild(preambleScript);
408
+
409
+ const viteClientScript = document.createElement("script");
410
+ viteClientScript.type = "module";
411
+ viteClientScript.src = `${this.testUrl?.origin}/@vite/client`;
412
+ document.head.appendChild(viteClientScript);
413
+ };
336
414
  }
337
415
 
338
416
  export interface CustomCodeComponent extends ContentTree.Node {
package/src/events.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  import { ComponentPath } from "./path";
2
2
 
3
- export class CCCConnectedEvent extends Event {
4
- static eventType = "ccc:connected";
5
-
3
+ export class CCCEvent extends Event {
6
4
  component: ComponentPath;
7
5
  source?: string;
6
+ static eventType = "ccc:event";
8
7
 
9
8
  constructor(
9
+ eventType: string = CCCEvent.eventType,
10
10
  detail: { component: ComponentPath; source?: string },
11
11
  opts?: EventInit
12
12
  ) {
13
- super(CCCConnectedEvent.eventType, {
13
+ super(eventType, {
14
14
  bubbles: true,
15
15
  cancelable: false,
16
16
  composed: true,
@@ -20,3 +20,45 @@ export class CCCConnectedEvent extends Event {
20
20
  this.source = detail.source;
21
21
  }
22
22
  }
23
+
24
+ export class CCCConnectedEvent extends CCCEvent {
25
+ static eventType = "ccc:connected";
26
+
27
+ constructor(
28
+ detail: { component: ComponentPath; source?: string },
29
+ opts?: EventInit
30
+ ) {
31
+ super(CCCConnectedEvent.eventType, detail, opts);
32
+ }
33
+ }
34
+
35
+ export class CCCReadyEvent extends CCCEvent {
36
+ static eventType = "ccc:ready";
37
+
38
+ constructor(
39
+ detail: { component: ComponentPath; source?: string },
40
+ opts?: EventInit
41
+ ) {
42
+ super(CCCReadyEvent.eventType, detail, opts);
43
+ }
44
+ }
45
+
46
+ export class CCCViewportEvent extends CCCEvent {
47
+ static eventType = "ccc:viewport";
48
+ intersecting = false;
49
+ entry?: IntersectionObserverEntry;
50
+
51
+ constructor(
52
+ detail: {
53
+ component: ComponentPath;
54
+ source?: string;
55
+ intersecting: boolean;
56
+ entry?: IntersectionObserverEntry;
57
+ },
58
+ opts?: EventInit
59
+ ) {
60
+ super(CCCViewportEvent.eventType, detail, opts);
61
+ this.intersecting = detail.intersecting;
62
+ this.entry = detail.entry;
63
+ }
64
+ }
package/src/path.ts CHANGED
@@ -12,7 +12,7 @@ export class ComponentPath {
12
12
  name: string;
13
13
  versionRange: string;
14
14
 
15
- constructor(path: ComponentPathType | string) {
15
+ constructor(path?: ComponentPathType | string) {
16
16
  const { org, repo, name, versionRange } = isValidComponentPathObject(path)
17
17
  ? path
18
18
  : ComponentPath.fromString(path);
@@ -36,12 +36,18 @@ export class ComponentPath {
36
36
  return `${this.org}/${this.repo}@${this.versionRange}/${this.name}`;
37
37
  }
38
38
 
39
+ get isValid(): boolean {
40
+ return [this.org, this.repo, this.name].every(
41
+ (value) => value !== "unknown"
42
+ );
43
+ }
44
+
39
45
  toString(): string {
40
46
  return this.path;
41
47
  }
42
48
 
43
- static fromString(path: string | null, v?: string | null): ComponentPath {
44
- if (!path) throw new CCCError("No path specified");
49
+ static fromString(p?: string | null, v?: string | null): ComponentPath {
50
+ const path = p ?? "unknown/unknown/unknown";
45
51
 
46
52
  const [name, repo, org] = path
47
53
  .replace(/@[^\/]+/, "")