@sitecore-feaas/clientside 0.5.4 → 0.5.6

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 (63) hide show
  1. package/build/dom/rendering.js +20 -11
  2. package/build/dom/rendering.js.map +1 -1
  3. package/build/dom/stylesheet.js +11 -12
  4. package/build/dom/stylesheet.js.map +1 -1
  5. package/build/fetch.js +46 -22
  6. package/build/fetch.js.map +1 -1
  7. package/build/utils/data.js +11 -8
  8. package/build/utils/data.js.map +1 -1
  9. package/build/utils/settings.js +1 -1
  10. package/build/utils/settings.js.map +1 -1
  11. package/dist/browser/headless.cjs +4 -4
  12. package/dist/browser/headless.cjs.map +3 -3
  13. package/dist/browser/headless.esm.js +5 -5
  14. package/dist/browser/headless.esm.js.map +1 -1
  15. package/dist/browser/index.cjs +5 -5
  16. package/dist/browser/index.cjs.map +3 -3
  17. package/dist/browser/index.esm.js +5 -5
  18. package/dist/browser/index.esm.js.map +1 -1
  19. package/dist/browser/react.cjs +5 -5
  20. package/dist/browser/react.cjs.map +3 -3
  21. package/dist/browser/react.esm.js +5 -5
  22. package/dist/browser/react.esm.js.map +1 -1
  23. package/dist/node/headless.cjs +5 -5
  24. package/dist/node/headless.cjs.map +3 -3
  25. package/dist/node/headless.esm.js +5 -5
  26. package/dist/node/headless.esm.js.map +3 -3
  27. package/dist/node/index.cjs +4 -4
  28. package/dist/node/index.cjs.map +3 -3
  29. package/dist/node/index.esm.js +4 -4
  30. package/dist/node/index.esm.js.map +3 -3
  31. package/dist/node/react.cjs +4 -4
  32. package/dist/node/react.cjs.map +3 -3
  33. package/dist/node/react.esm.js +4 -4
  34. package/dist/node/react.esm.js.map +3 -3
  35. package/headless.cjs +5 -5
  36. package/headless.cjs.map +3 -3
  37. package/headless.js +5 -5
  38. package/headless.js.map +3 -3
  39. package/package.json +1 -1
  40. package/react.cjs +5 -5
  41. package/react.cjs.map +3 -3
  42. package/react.js +5 -5
  43. package/react.js.map +3 -3
  44. package/src/dom/rendering.ts +31 -16
  45. package/src/dom/stylesheet.ts +17 -15
  46. package/src/dom/thumbnail.ts +1 -1
  47. package/src/fetch.ts +54 -30
  48. package/src/utils/data.ts +11 -7
  49. package/src/utils/settings.ts +1 -1
  50. package/test/FEAASComponent.react.test.tsx +19 -3
  51. package/test/FEAASComponent.test.tsx +117 -20
  52. package/test/FEAASStylesheet.test.tsx +13 -11
  53. package/test/datasettings.test.ts +92 -0
  54. package/types/dom/rendering.d.ts.map +1 -1
  55. package/types/dom/stylesheet.d.ts +1 -1
  56. package/types/dom/stylesheet.d.ts.map +1 -1
  57. package/types/dom/thumbnail.d.ts +1 -1
  58. package/types/dom/thumbnail.d.ts.map +1 -1
  59. package/types/fetch.d.ts +39 -15
  60. package/types/fetch.d.ts.map +1 -1
  61. package/types/utils/data.d.ts +2 -2
  62. package/types/utils/data.d.ts.map +1 -1
  63. package/types/utils/settings.d.ts +5 -5
@@ -96,23 +96,33 @@ export function setDOMAttribute(element: HTMLElement, attribute: string, value:
96
96
  } else if (attribute == 'data-embed-src') {
97
97
  loadScript(element, value)
98
98
  } else if (attribute == 'data-path-attributes') {
99
- const oldKeys = element.getAttribute('data-attributes-keys')?.split(',').filter(Boolean) || []
100
- if (!Array.isArray(value))
101
- for (var property in value) {
102
- var subvalue = value[property]
103
- setDOMAttribute(
104
- element,
105
- property,
106
- typeof subvalue == 'object' && subvalue ? JSON.stringify(subvalue) : subvalue,
107
- config
108
- )
109
- }
110
- for (var i = 0; i < oldKeys.length; i++) {
111
- if (value == null || !(oldKeys[i] in value)) {
112
- setDOMAttribute(element, oldKeys[i], null, config)
99
+ if (!Array.isArray(value)) {
100
+ const oldKeys = element.getAttribute('data-attributes-keys')?.split(',').filter(Boolean) || []
101
+ if (!Array.isArray(value))
102
+ for (var property in value) {
103
+ var subvalue = value[property]
104
+ setDOMAttribute(
105
+ element,
106
+ property,
107
+ typeof subvalue == 'object' && subvalue ? JSON.stringify(subvalue) : subvalue,
108
+ config
109
+ )
110
+ }
111
+ for (var i = 0; i < oldKeys.length; i++) {
112
+ if (value == null || !(oldKeys[i] in value)) {
113
+ setDOMAttribute(element, oldKeys[i], null, config)
114
+ }
113
115
  }
116
+ setDOMAttribute(element, 'data-attributes-keys', Object.keys(value || {}).join(','))
117
+ }
118
+ // BYOC components also accept their data object in datasources attribute.
119
+ if (element.tagName == 'FEAAS-EXTERNAL') {
120
+ const datasourceId =
121
+ String(element.getAttribute('data-path-attributes') || '')
122
+ .slice(0, -1)
123
+ .split('.')[0] || '_'
124
+ setDOMAttribute(element, 'datasources', JSON.stringify({ [datasourceId]: value }))
114
125
  }
115
- setDOMAttribute(element, 'data-attributes-keys', Object.keys(value || {}).join(','))
116
126
  return
117
127
  }
118
128
  if (typeof value == 'boolean') {
@@ -198,7 +208,12 @@ export function renderDOMElement(
198
208
  for (var i = 0; i < attrs.length; i++) {
199
209
  const { name, value: datapath } = attrs[i]
200
210
  if (name.startsWith('data-path') && name != 'data-path-scope') {
201
- setDOMAttribute(element, name, queryScopes(datascopes, datapath, true), config)
211
+ setDOMAttribute(
212
+ element,
213
+ name,
214
+ queryScopes(datascopes, datapath, true, undefined, element.tagName != 'FEAAS-EXTERNAL'),
215
+ config
216
+ )
202
217
  }
203
218
  if (name == 'data-embed-src') {
204
219
  setDOMAttribute(element, name, element.getAttribute(name), config)
@@ -17,23 +17,25 @@ export function renderStylesheetPromise(props: FEAASCDNStylesheetProps, styleshe
17
17
  }).then(() => stylesheet)
18
18
  }
19
19
 
20
+ const validatedStylesheets = new WeakSet<HTMLStyleElement | HTMLLinkElement>()
20
21
  export function loadStylesheet(props: FEAASCDNStylesheetProps, callback?: ValidatableStylesheetCallback) {
21
- var foundStylesheet = findStylesheet(props) as HTMLStyleElement
22
- if (!foundStylesheet) {
23
- var createdStylesheet = createStylesheet(props)
24
- } else {
25
- if (foundStylesheet.tagName == 'LINK') {
26
- callback(null, 'initial')
27
- return Promise.resolve(foundStylesheet)
28
- }
22
+ var foundStylesheet: HTMLStyleElement | HTMLLinkElement
23
+ var writableStylesheet: HTMLStyleElement
24
+
25
+ if (foundStylesheet && validatedStylesheets.has(foundStylesheet)) {
26
+ return Promise.resolve(foundStylesheet)
29
27
  }
30
28
 
31
29
  return fetchAndRevalidateStylesheet(props, (cssText, phase) => {
32
- if (createdStylesheet) {
33
- createdStylesheet.textContent = cssText
30
+ foundStylesheet ||= findStylesheet(props)
31
+ // dont need to write stylesheet for cached state if we already found a stylesheet
32
+ if (!(phase == 'initial' && foundStylesheet)) {
33
+ writableStylesheet ||=
34
+ !foundStylesheet || foundStylesheet.tagName == 'LINK' ? createStylesheet(props) : foundStylesheet
35
+ writableStylesheet.textContent = cssText
34
36
  }
35
37
  callback?.(cssText, phase)
36
- }).then(() => foundStylesheet || createdStylesheet)
38
+ }).then(() => foundStylesheet || writableStylesheet)
37
39
  }
38
40
 
39
41
  export function loadStylesheetAllowStale(props: FEAASCDNStylesheetProps) {
@@ -43,10 +45,10 @@ export function loadStylesheetAllowStale(props: FEAASCDNStylesheetProps) {
43
45
  }
44
46
 
45
47
  export function findStylesheet(props: FEAASCDNStylesheetProps) {
46
- return (
47
- document.head.querySelector(`style[data-href="${getStylesheetURL(props)}"]`) ||
48
- document.querySelector(`link[rel="stylesheet"][href="${getStylesheetURL(props)}"]`)
49
- )
48
+ return (document.head.querySelector(`style[data-href="${getStylesheetURL(props)}"]`) ||
49
+ document.querySelector(`link[rel="stylesheet"][href="${getStylesheetURL(props)}"]`)) as
50
+ | HTMLStyleElement
51
+ | HTMLLinkElement
50
52
  }
51
53
 
52
54
  export function createStylesheet(props: FEAASCDNStylesheetProps) {
@@ -2,7 +2,7 @@ import type { SDK, Style, VersionModel, ComponentModel, StylesheetModel } from '
2
2
  import { fetchAndRevalidate } from '../fetch.js'
3
3
  import { renderComponent } from './component.js'
4
4
 
5
- export type Callback = (image: HTMLImageElement, isCached: boolean) => void
5
+ export type Callback = (image: HTMLImageElement | null, isCached: boolean) => void
6
6
 
7
7
  export const convertBase64toBlob = (base64: string) => fetch(base64).then((res) => res.blob())
8
8
 
package/src/fetch.ts CHANGED
@@ -1,17 +1,5 @@
1
1
  import { ControlledPromise } from './utils/promise.js'
2
2
 
3
- /**
4
- * Utility to cache-bust static files. It relies on reading Date header to detect cached response, so it requires CORS
5
- * settings to be set.
6
- *
7
- * It accepts validator function that determines if response is usable. If response was pulled from cache and is valid,
8
- * the optional callback function is fired right away. Then HEAD request is made bypassing the cache, to see if the
9
- * response has an newer Last-Modified header. In case it does and it passes validation, third request is fired that
10
- * actually fetches the new content and callback is invoked the second time.
11
- *
12
- * It works as promise, but also accepts callback to use cached blobs mid-flight and have access to headers. Any network
13
- * errors will not be caught. Function throws when unable to fetch valid response.
14
- */
15
3
  const phases = ['initial', 'check', 'updated'] as const
16
4
  export type ValidatableRequestPhase = (typeof phases)[number]
17
5
  export type FetchCache = Record<string, FetchCacheRequest>
@@ -38,6 +26,18 @@ export function clearFetchCache() {
38
26
  fetchCache = {}
39
27
  }
40
28
 
29
+ /**
30
+ * Utility to cache-bust static files. It relies on reading Date header to detect cached response, so it requires CORS
31
+ * settings to be set.
32
+ *
33
+ * It accepts validator function that determines if response is usable. If response was pulled from cache and is valid,
34
+ * the optional callback function is fired right away. Then HEAD request is made bypassing the cache, to see if the
35
+ * response has an newer Last-Modified header. In case it does and it passes validation, third request is fired that
36
+ * actually fetches the new content and callback is invoked the second time.
37
+ *
38
+ * It works as promise, but also accepts callback to use cached blobs mid-flight and have access to headers. Any network
39
+ * errors will not be caught. Function throws when unable to fetch valid response.
40
+ */
41
41
  export async function fetchAndRevalidate(
42
42
  url: ValidatableRequest['url'],
43
43
  options?: ValidatableRequest['options'],
@@ -51,23 +51,22 @@ export async function fetchAndRevalidate(
51
51
  .then((result) => result.response)
52
52
  }
53
53
 
54
- export function getFetchCache(url: Parameters<typeof fetch>[0], dontCreate = false) {
55
- const key = String(url).replace('?head', '')
56
- var cache = fetchCache[key]
57
- if (!cache && !dontCreate) {
58
- cache = fetchCache[key] = {
59
- phase: null,
60
- initial: ControlledPromise(),
61
- check: ControlledPromise(),
62
- updated: ControlledPromise(),
63
- final: ControlledPromise()
64
- }
65
- }
66
- return cache
67
- }
68
-
69
- export class InvalidResponse extends Error {}
70
-
54
+ /**
55
+ * Executes a fetch request optionally validating and handling the response. Using callback function it's possible to
56
+ * observe successful response twice: Cached version first, and up-to-date later.
57
+ *
58
+ * @param {Object} request - An object containing request parameters.
59
+ * @param {string} request.url - The URL to fetch.
60
+ * @param {Object} request.options - Fetch request options.
61
+ * @param {Function} [request.validator] - Function to validate the response. Should return `false` if
62
+ * validation fails.
63
+ * @param {Function} [request.callback] - Function to execute after fetching. Receives the response,
64
+ * phase, and cache status.
65
+ * @param {Object} [request.cache] - Cache object to store or retrieve cached data.
66
+ * @param {ValidatableRequestPhase} [phase='initial'] - The phase of the request, defaults to 'initial'.
67
+ * @returns {Promise<Object>} A promise that resolves to an object containing request details and the response.
68
+ * @throws {Error} Throws an error if the fetch fails or if the response is not valid according to the validator.
69
+ */
71
70
  export async function fetchWithCacheAwareness(
72
71
  { url, options, validator, callback, cache }: ValidatableRequest,
73
72
  phase: ValidatableRequestPhase = 'initial'
@@ -107,6 +106,31 @@ export async function fetchWithCacheAwareness(
107
106
  }
108
107
  }
109
108
 
109
+ export function getFetchCache(url: Parameters<typeof fetch>[0], dontCreate = false) {
110
+ const key = String(url).replace('?head', '')
111
+ var cache = fetchCache[key]
112
+ if (!cache && !dontCreate) {
113
+ cache = fetchCache[key] = {
114
+ phase: null,
115
+ initial: ControlledPromise(),
116
+ check: ControlledPromise(),
117
+ updated: ControlledPromise(),
118
+ final: ControlledPromise()
119
+ }
120
+ }
121
+ return cache
122
+ }
123
+
124
+ export class InvalidResponse extends Error {}
125
+
126
+ /**
127
+ * Validates cache of previousl fetch If the resource is cached and there is a newer version available, it will be
128
+ * re-fetched.
129
+ *
130
+ * @param request - The request object containing the URL, cache information, options, validator, and response of
131
+ * initial request.
132
+ * @returns A promise that resolves to the request object.
133
+ */
110
134
  export async function fetchToValidateCache(
111
135
  request: ValidatableRequest & Required<Pick<ValidatableRequest, 'response'>>
112
136
  ) {
@@ -129,7 +153,7 @@ export async function fetchToValidateCache(
129
153
 
130
154
  // there's a newer version of the file that we may fetch
131
155
  if (head.headers.get('last-modified') != response.headers.get('last-modified')) {
132
- // otherwise the new resource has to be re-fetched
156
+ // the new resource has to be re-fetched
133
157
  return fetchBypassingCache(request)
134
158
  }
135
159
  }
package/src/utils/data.ts CHANGED
@@ -7,11 +7,15 @@ export function queryObjectRaw(json: any, path: string) {
7
7
  return []
8
8
  }
9
9
  }
10
- export function queryObject(json: any, path: string) {
11
- return queryObjectRaw(json, path)?.flat(Infinity) || []
10
+ export function queryObject(json: any, path: string, flatten = true) {
11
+ const data = queryObjectRaw(json, path) || []
12
+ if (flatten) {
13
+ return data.flat(Infinity)
14
+ }
15
+ return data
12
16
  }
13
17
 
14
- export function queryScopes(scopes: any, datapath: string, singleValue = false, json: any = scopes) {
18
+ export function queryScopes(scopes: any, datapath: string, singleValue = false, json: any = scopes, flatten = true) {
15
19
  var path = datapath
16
20
  /* find closest parent scope */
17
21
  const keys = Object.keys(scopes).sort((b, a) => a.length - b.length)
@@ -30,11 +34,11 @@ export function queryScopes(scopes: any, datapath: string, singleValue = false,
30
34
  }
31
35
  }
32
36
 
33
- var collection = queryObject(json, path)
37
+ var collection = queryObject(json, path, flatten)
34
38
 
35
39
  // If regular path didn't match any data, try fallbacks
36
40
  // datasourceId.wrapper.$.path
37
- if (Array.isArray(collection) && collection?.length == 0) {
41
+ if (collection.length == 0) {
38
42
  collection = [
39
43
  // look up in `_` datasource id (catch-all id is used in JSS)
40
44
  // _.wrapper.$.path
@@ -56,11 +60,11 @@ export function queryScopes(scopes: any, datapath: string, singleValue = false,
56
60
  ]
57
61
  .filter((v, i, a) => a.indexOf(v) === i)
58
62
  .reduce((v, path) => {
59
- return v.length > 0 ? v : queryObject(json, path)
63
+ return v.length > 0 ? v : queryObject(json, path, flatten)
60
64
  }, [] as any[])
61
65
  }
62
66
 
63
- if (singleValue && Array.isArray(collection)) {
67
+ if (singleValue) {
64
68
  return collection[0]
65
69
  } else {
66
70
  return collection
@@ -52,7 +52,7 @@ export function clean(settings: any) {
52
52
  * @returns {boolean} - True if the data is of type DataSettings, false otherwise.
53
53
  */
54
54
  export const isDataSettings = (data: any): data is DataSettings => {
55
- if (typeof data.url != 'string') return false
55
+ if (!data || typeof data.url != 'string') return false
56
56
 
57
57
  for (var property in data) {
58
58
  if (
@@ -9,8 +9,8 @@ function Hello(props: { firstName: string; telephone: string }) {
9
9
  return React.createElement(
10
10
  'div',
11
11
  {},
12
- `Hello my name is ${props.firstName} (${typeof props.firstName}) and my tel is ${
13
- props.telephone
12
+ `Hello my name is ${props.firstName || 'Unknown'} (${typeof props.firstName}) and my tel is ${
13
+ props.telephone || 'Unknown'
14
14
  } (${typeof props.telephone}). And context value is ${context}`
15
15
  )
16
16
  }
@@ -169,7 +169,23 @@ describe('React', () => {
169
169
  ></Component>
170
170
  </section>
171
171
  </DataContext.Provider>,
172
- '<section><feaas-component class="-feaas" data="{&quot;telephone&quot;:321}"><feaas-external data-external-id="MyTestComponent" data-path-attributes="$" first-name="Yarik" telephone="321" data-attributes-keys="telephone" hydrate="false"><div>Hello my name is Yarik (string) and my tel is 321 (number). And context value is INHERITED</div></feaas-external></feaas-component></section>'
172
+ '<section><feaas-component class="-feaas" data="{&quot;telephone&quot;:321}"><feaas-external data-external-id="MyTestComponent" data-path-attributes="$" first-name="Yarik" telephone="321" data-attributes-keys="telephone" datasources="{&quot;_&quot;:{&quot;telephone&quot;:321}}" hydrate="false"><div>Hello my name is Yarik (string) and my tel is 321 (number). And context value is INHERITED</div></feaas-external></feaas-component></section>'
173
+ )
174
+ })
175
+ it('render not map attributes of external component with given array', async () => {
176
+ registerComponent(Hello, helloSchema)
177
+ validate(
178
+ <DataContext.Provider value='INHERITED'>
179
+ <section>
180
+ <Component
181
+ template={
182
+ '<feaas-external first-name="Yarik" data-path-attributes="$" data-external-id="MyTestComponent"></feaas-external>'
183
+ }
184
+ data={[{ telephone: 321 }]}
185
+ ></Component>
186
+ </section>
187
+ </DataContext.Provider>,
188
+ '<section><feaas-component class="-feaas" data="[{&quot;telephone&quot;:321}]"><feaas-external data-external-id="MyTestComponent" first-name="Yarik" data-path-attributes="$" datasources="{&quot;_&quot;:[{&quot;telephone&quot;:321}]}" hydrate="false"><div>Hello my name is Yarik (string) and my tel is Unknown (undefined). And context value is INHERITED</div></feaas-external></feaas-component></section>'
173
189
  )
174
190
  })
175
191
  })
@@ -25,7 +25,7 @@ describe('Components', () => {
25
25
  const spy = vi.spyOn(global, 'fetch')
26
26
  spy.mockImplementation(async (url: string, options: Parameters<typeof fetch>[1] = {}) => {
27
27
  if (url.match('CDN/components/L/C/V/R')) {
28
- return new Response('TODAY', {
28
+ return new Response('FRESH', {
29
29
  headers: {
30
30
  date: now.toUTCString(),
31
31
  'last-modified': earlier.toUTCString()
@@ -43,7 +43,7 @@ describe('Components', () => {
43
43
  await expect(component.whenRendered).resolves.toEqual(component)
44
44
  expect(spy).toHaveBeenCalledTimes(1)
45
45
 
46
- expect(component.innerHTML).toContain('TODAY')
46
+ expect(component.innerHTML).toContain('FRESH')
47
47
  })
48
48
 
49
49
  describe('fetch control', () => {
@@ -116,7 +116,7 @@ describe('Components', () => {
116
116
  const spy = vi.spyOn(global, 'fetch')
117
117
  spy.mockImplementation(async (url: string, options: Parameters<typeof fetch>[1] = {}) => {
118
118
  if (url.match('CDN/components/L/C/V/R')) {
119
- return new Response('TODAY', {
119
+ return new Response('FRESH', {
120
120
  headers: {
121
121
  date: now.toUTCString(),
122
122
  'last-modified': earlier.toUTCString()
@@ -139,7 +139,7 @@ describe('Components', () => {
139
139
 
140
140
  await expect(component.whenRendered).resolves.toEqual(component)
141
141
  expect(spy).toHaveBeenCalledTimes(2)
142
- expect(component.innerHTML).toBe('TODAY')
142
+ expect(component.innerHTML).toBe('FRESH')
143
143
  expect(document.head.querySelectorAll('style').length).toEqual(1)
144
144
  })
145
145
  it('should make data/css requests when fetch="stylesheet data"', async () => {
@@ -208,7 +208,7 @@ describe('Components', () => {
208
208
  const spy = vi.spyOn(global, 'fetch')
209
209
  spy.mockImplementation(async (url: string, options: Parameters<typeof fetch>[1] = {}) => {
210
210
  if (url.match('http://a/components/b/c/d/1.html')) {
211
- return new Response('TODAY', {
211
+ return new Response('FRESH', {
212
212
  headers: {
213
213
  date: now.toUTCString(),
214
214
  'last-modified': earlier.toUTCString()
@@ -226,7 +226,7 @@ describe('Components', () => {
226
226
  expect(spy).toHaveBeenCalledTimes(1)
227
227
 
228
228
  expect(component.outerHTML).toBe(
229
- `<feaas-component fetch="template data" src="http://a/components/b/c/d/1.html" cdn="http://a" library="b" component="c" version="d" revision="1" class="-feaas">TODAY</feaas-component>`
229
+ `<feaas-component fetch="template data" src="http://a/components/b/c/d/1.html" cdn="http://a" library="b" component="c" version="d" revision="1" class="-feaas">FRESH</feaas-component>`
230
230
  )
231
231
  })
232
232
 
@@ -234,7 +234,7 @@ describe('Components', () => {
234
234
  const spy = vi.spyOn(global, 'fetch')
235
235
  spy.mockImplementation(async (url: string, options: Parameters<typeof fetch>[1] = {}) => {
236
236
  if (url.match('http://a/components/b/c/d/1.html')) {
237
- return new Response('TODAY', {
237
+ return new Response('FRESH', {
238
238
  headers: {
239
239
  date: now.toUTCString(),
240
240
  'last-modified': earlier.toUTCString()
@@ -260,16 +260,63 @@ describe('Components', () => {
260
260
  expect(document.head.querySelectorAll('style')[1].getAttribute('data-href')).toBe('http://a/styles/b/staged.css')
261
261
  })
262
262
 
263
- it('should render component if stylesheet is statically embedded as link', async () => {
263
+ it('should validate stylesheet when included as link before rendering component', async () => {
264
264
  const spy = vi.spyOn(global, 'fetch')
265
265
  spy.mockImplementation(async (url: string, options: Parameters<typeof fetch>[1] = {}) => {
266
266
  if (url.match('http://a/components/b/c/d/1.html')) {
267
- return new Response('TODAY', {
267
+ return new Response('FRESH', {
268
268
  headers: {
269
269
  date: now.toUTCString(),
270
270
  'last-modified': earlier.toUTCString()
271
271
  }
272
272
  })
273
+ } else if (url.match('.css')) {
274
+ return new Response('FRESH', {
275
+ headers: {
276
+ date: /*String(options?.method).toLowerCase() == 'head' ? */ now.toUTCString(),
277
+ 'last-modified': earlier.toUTCString()
278
+ }
279
+ })
280
+ }
281
+ })
282
+
283
+ document.body.innerHTML =
284
+ '<feaas-component src="http://a/components/b/c/d/1.html"></feaas-component><link rel="stylesheet" href="http://a/styles/b/published.css" />'
285
+
286
+ const component = document.body.firstElementChild as FEAASComponent
287
+ await expect(component.whenRendered).resolves.toEqual(component)
288
+ expect(spy).toHaveBeenCalledTimes(2)
289
+ expect(document.head.querySelectorAll('style').length).toBe(0)
290
+ })
291
+
292
+ it('should validate and re-write stylesheet when included as link before rendering component', async () => {
293
+ const spy = vi.spyOn(global, 'fetch')
294
+ spy.mockImplementation(async (url: string, options: Parameters<typeof fetch>[1] = {}) => {
295
+ if (url.match('http://a/components/b/c/d/1.html')) {
296
+ return new Response('FRESH', {
297
+ headers: {
298
+ date: now.toUTCString(),
299
+ 'last-modified': earlier.toUTCString()
300
+ }
301
+ })
302
+ } else if (url.match('.css')) {
303
+ if (!options?.cache) {
304
+ // respond with cached css
305
+ return new Response('CACHED', {
306
+ headers: {
307
+ date: earliest.toUTCString(),
308
+ 'last-modified': earliest.toUTCString()
309
+ }
310
+ })
311
+ } else {
312
+ // respond with up to date
313
+ return new Response('CACHED', {
314
+ headers: {
315
+ date: now.toUTCString(),
316
+ 'last-modified': earliest.toUTCString()
317
+ }
318
+ })
319
+ }
273
320
  }
274
321
  })
275
322
 
@@ -278,15 +325,65 @@ describe('Components', () => {
278
325
 
279
326
  const component = document.body.firstElementChild as FEAASComponent
280
327
  await expect(component.whenRendered).resolves.toEqual(component)
328
+ expect(spy).toHaveBeenCalledTimes(3)
329
+ // render with stale stylesheet
330
+ expect(document.head.querySelectorAll('style').length).toBe(0)
331
+ await new Promise((r) => setTimeout(r, 50))
332
+ expect(spy).toHaveBeenCalledTimes(3)
281
333
  expect(document.head.querySelectorAll('style').length).toBe(0)
282
334
  })
283
335
 
336
+ it('should validate and re-write stylesheet when included as link before rendering component', async () => {
337
+ const spy = vi.spyOn(global, 'fetch')
338
+ spy.mockImplementation(async (url: string, options: Parameters<typeof fetch>[1] = {}) => {
339
+ if (url.match('http://a/components/b/c/d/1.html')) {
340
+ return new Response('FRESH', {
341
+ headers: {
342
+ date: now.toUTCString(),
343
+ 'last-modified': earlier.toUTCString()
344
+ }
345
+ })
346
+ } else if (url.match('.css')) {
347
+ if (!options?.cache) {
348
+ // respond with cached css
349
+ return new Response('CACHED', {
350
+ headers: {
351
+ date: earliest.toUTCString(),
352
+ 'last-modified': earliest.toUTCString()
353
+ }
354
+ })
355
+ } else {
356
+ if (String(options?.method).toLowerCase() == 'head') await new Promise((r) => setTimeout(r, 50))
357
+ // respond with up to date
358
+ return new Response('FRESH', {
359
+ headers: {
360
+ date: now.toUTCString(),
361
+ 'last-modified': now.toUTCString()
362
+ }
363
+ })
364
+ }
365
+ }
366
+ })
367
+
368
+ document.body.innerHTML =
369
+ '<feaas-component src="http://a/components/b/c/d/1.html"></feaas-component><link rel="stylesheet" href="http://a/styles/b/published.css" />'
370
+
371
+ const component = document.body.firstElementChild as FEAASComponent
372
+ await expect(component.whenRendered).resolves.toEqual(component)
373
+ expect(spy).toHaveBeenCalledTimes(3)
374
+ // render with stale stylesheet
375
+ expect(document.head.querySelectorAll('style').length).toBe(0)
376
+ await new Promise((r) => setTimeout(r, 100))
377
+ expect(spy).toHaveBeenCalledTimes(4)
378
+ expect(document.head.querySelectorAll('style').length).toBe(1)
379
+ })
380
+
284
381
  it('should revalidate cached component response', async () => {
285
382
  const spy = vi.spyOn(global, 'fetch')
286
383
  spy.mockImplementation(async (url: string, options: Parameters<typeof fetch>[1] = {}) => {
287
384
  if (url.match('CDN/components/L/C/V/R') && options.method != 'HEAD') {
288
385
  // 1. Here's cached component
289
- return new Response('TODAY', {
386
+ return new Response('CACHED', {
290
387
  headers: {
291
388
  date: earlier.toUTCString(),
292
389
  'last-modified': earlier.toUTCString()
@@ -311,7 +408,7 @@ describe('Components', () => {
311
408
  await expect(component.whenRendered).resolves.toEqual(component)
312
409
  expect(spy).toHaveBeenCalledTimes(2)
313
410
 
314
- expect(component.innerHTML).toContain('TODAY')
411
+ expect(component.innerHTML).toContain('CACHED')
315
412
  })
316
413
 
317
414
  it('should reload component while using stale cache', async () => {
@@ -335,7 +432,7 @@ describe('Components', () => {
335
432
  })
336
433
  } else {
337
434
  // Up to date version
338
- return new Response('TODAY', {
435
+ return new Response('FRESH', {
339
436
  headers: {
340
437
  date: now.toUTCString(),
341
438
  'last-modified': earlier.toUTCString()
@@ -356,7 +453,7 @@ describe('Components', () => {
356
453
  // whenLoaded only fires when ensures that up-to-date version is loaded
357
454
  await expect(component.whenLoaded).resolves.toBeTruthy()
358
455
  expect(spy).toHaveBeenCalledTimes(3)
359
- expect(component.innerHTML).toContain('TODAY')
456
+ expect(component.innerHTML).toContain('FRESH')
360
457
  })
361
458
 
362
459
  it('should re-fetch component when attributes change', async () => {
@@ -418,7 +515,7 @@ describe('Components', () => {
418
515
  })
419
516
  } else {
420
517
  await new Promise((resolve) => setTimeout(resolve, 200))
421
- return new Response('TODAY {}', {
518
+ return new Response('FRESH {}', {
422
519
  headers: {
423
520
  date: now.toUTCString(),
424
521
  'last-modified': earlier.toUTCString()
@@ -445,7 +542,7 @@ describe('Components', () => {
445
542
  } else {
446
543
  // Latest
447
544
  await new Promise((resolve) => setTimeout(resolve, 100))
448
- return new Response('TODAY', {
545
+ return new Response('FRESH', {
449
546
  headers: {
450
547
  date: now.toUTCString(),
451
548
  'last-modified': earlier.toUTCString()
@@ -459,20 +556,20 @@ describe('Components', () => {
459
556
  const component = document.body.firstElementChild as FEAASComponent
460
557
  expect(document.head.querySelector('style')).toBeNull()
461
558
  await new Promise((resolve) => setTimeout(resolve, 50))
462
- const stylesheet = document.head.querySelector('style') as HTMLStyleElement
463
- expect(stylesheet.innerHTML).toEqual('')
559
+ expect(document.head.querySelector('style')).toBeNull()
464
560
  expect(component.innerHTML).toEqual('')
465
561
  // Component will rendered with stale cached stylesheet
466
562
  await expect(component.whenRendered).resolves.toEqual(component)
563
+ const stylesheet = document.head.querySelector('style') as HTMLStyleElement
467
564
  expect(stylesheet.innerHTML).toEqual('YESTERDAY {}')
468
565
  expect(component.innerHTML).toEqual('YESTERDAY')
469
566
  // Component will update itself with stale cached stylesheet
470
- await expect(component.whenLoaded).resolves.toEqual('TODAY')
567
+ await expect(component.whenLoaded).resolves.toEqual('FRESH')
471
568
  expect(stylesheet.innerHTML).toEqual('YESTERDAY {}')
472
- expect(component.innerHTML).toEqual('TODAY')
569
+ expect(component.innerHTML).toEqual('FRESH')
473
570
  await new Promise((resolve) => setTimeout(resolve, 200))
474
571
  // Stylesheet will update later
475
- expect(stylesheet.innerHTML).toEqual('TODAY {}')
572
+ expect(stylesheet.innerHTML).toEqual('FRESH {}')
476
573
  })
477
574
 
478
575
  it('render without data', () => {