@supersoniks/concorde 4.7.0 → 4.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersoniks/concorde",
3
- "version": "4.7.0",
3
+ "version": "4.7.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "",
@@ -22,6 +22,13 @@ async function flushGetMicrotasks() {
22
22
  await Promise.resolve();
23
23
  }
24
24
 
25
+ /** @get scoped : scheduleAfterHostReady diffère le fetch (rAF ou updateComplete Lit). */
26
+ async function flushScopedGet() {
27
+ await flushGetMicrotasks();
28
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
29
+ await flushGetMicrotasks();
30
+ }
31
+
25
32
  function mockPayload<T>(result: T) {
26
33
  return {
27
34
  request: new Request("https://api.example.test/items/1"),
@@ -113,7 +120,7 @@ describe("get", () => {
113
120
  wrap.appendChild(el);
114
121
  document.body.appendChild(wrap);
115
122
 
116
- await flushGetMicrotasks();
123
+ await flushScopedGet();
117
124
 
118
125
  expect(API.prototype.getDetailed).toHaveBeenCalledWith("scoped-endpoint");
119
126
  expect(el.payload?.result).toEqual({ id: "loaded" });
@@ -47,11 +47,108 @@ function resolveScopedConfiguration(
47
47
  return HTML.getApiConfiguration(host);
48
48
  }
49
49
 
50
+ function isScopedConfigurationReady(
51
+ config: APIConfiguration | null,
52
+ ): config is APIConfiguration {
53
+ return typeof config?.serviceURL === "string" && config.serviceURL.length > 0;
54
+ }
55
+
56
+ /** Attributs scope dont la présence peut déclencher un GET différé (Lit → minuscules). */
57
+ const SCOPE_WATCH_ATTRIBUTES = [
58
+ "serviceURL",
59
+ "serviceurl",
60
+ "token",
61
+ "credentials",
62
+ "tokenProvider",
63
+ "tokenprovider",
64
+ "userName",
65
+ "username",
66
+ "password",
67
+ "eventsApiToken",
68
+ "eventsapitoken",
69
+ ] as const;
70
+
71
+ type LitHost = HTMLElement & { updateComplete?: Promise<unknown> };
72
+
73
+ function isLitHost(component: unknown): component is LitHost {
74
+ return (
75
+ component instanceof HTMLElement &&
76
+ typeof (component as LitHost).updateComplete !== "undefined"
77
+ );
78
+ }
79
+
80
+ function scheduleAfterHostReady(
81
+ component: unknown,
82
+ callback: () => void,
83
+ ): () => void {
84
+ if (isLitHost(component)) {
85
+ let cancelled = false;
86
+ void component.updateComplete!.then(() => {
87
+ if (!cancelled) callback();
88
+ });
89
+ return () => {
90
+ cancelled = true;
91
+ };
92
+ }
93
+ const rafId = requestAnimationFrame(() => callback());
94
+ return () => cancelAnimationFrame(rafId);
95
+ }
96
+
97
+ /**
98
+ * Attend que la config scope soit lisible (attributs DOM ou propriétés Lit reflect).
99
+ */
100
+ function watchScopedConfiguration(
101
+ component: unknown,
102
+ onReady: () => void,
103
+ ): () => void {
104
+ const host = asSearchableHost(component);
105
+ if (!host) return () => {};
106
+
107
+ let settled = false;
108
+ const tryNotify = () => {
109
+ if (settled) return;
110
+ if (!isScopedConfigurationReady(resolveScopedConfiguration(component))) {
111
+ return;
112
+ }
113
+ settled = true;
114
+ onReady();
115
+ };
116
+
117
+ tryNotify();
118
+ if (settled) return () => {};
119
+
120
+ const cleanups: Array<() => void> = [];
121
+
122
+ const rafId = requestAnimationFrame(() => tryNotify());
123
+ cleanups.push(() => cancelAnimationFrame(rafId));
124
+
125
+ queueMicrotask(() => tryNotify());
126
+
127
+ const observer = new MutationObserver(() => tryNotify());
128
+ let node: SearchableDomElement | null = host;
129
+ while (node) {
130
+ if (node instanceof Element) {
131
+ observer.observe(node, {
132
+ attributes: true,
133
+ attributeFilter: [...SCOPE_WATCH_ATTRIBUTES],
134
+ });
135
+ }
136
+ node = (node.parentNode || (node as ShadowRoot).host) as SearchableDomElement;
137
+ }
138
+ cleanups.push(() => observer.disconnect());
139
+
140
+ return () => {
141
+ settled = true;
142
+ cleanups.forEach((cleanup) => cleanup());
143
+ };
144
+ }
145
+
50
146
  type ApiGetState = {
51
147
  cleanupWatchers: Array<() => void>;
52
148
  requestGeneration: number;
53
149
  configPublisher: DataProvider | null;
54
150
  configMutationHandler: (() => void) | null;
151
+ scopeWatchCleanup: (() => void) | null;
55
152
  };
56
153
 
57
154
  function detachConfigPublisher(state: ApiGetState): void {
@@ -68,7 +165,8 @@ function detachConfigPublisher(state: ApiGetState): void {
68
165
  * Le path est un `Endpoint<T, Ue>` ; les placeholders `${nomPropriété}` sont résolus sur l'instance (`Ue` contraint l’hôte).
69
166
  *
70
167
  * **Scoped (défaut)** : `HTML.getApiConfiguration(host)` avec `host` = l’élément connecté
71
- * (`HTMLElement` / `ShadowRoot`). Sans hôte DOM valide, la propriété reste inchangée jusqu’à connexion.
168
+ * (`HTMLElement` / `ShadowRoot`). Si `serviceURL` n’est pas encore disponible (reflect Lit, scope
169
+ * ancêtre), le GET est différé jusqu’à ce que la config soit prête.
72
170
  *
73
171
  * **Deuxième paramètre** : `DataProviderKey<APIConfiguration>` — la config est lue via
74
172
  * `PublisherManager` sur le chemin résolu (même syntaxe dynamique que `@subscribe`).
@@ -138,6 +236,7 @@ export function get<T, Ue = any, Uk = any>(
138
236
  requestGeneration: 0,
139
237
  configPublisher: null,
140
238
  configMutationHandler: null,
239
+ scopeWatchCleanup: null,
141
240
  };
142
241
  comp[stateKey] = state;
143
242
  }
@@ -166,8 +265,15 @@ export function get<T, Ue = any, Uk = any>(
166
265
  } else {
167
266
  config = resolveScopedConfiguration(component);
168
267
  }
169
- if (!config) {
170
- comp[propertyKey] = undefined;
268
+ if (!isScopedConfigurationReady(config)) {
269
+ if (!usesPublisherConfig && !state.scopeWatchCleanup) {
270
+ const scopeWatch = watchScopedConfiguration(component, runFetch);
271
+ state.scopeWatchCleanup = scopeWatch;
272
+ state.cleanupWatchers.push(() => {
273
+ scopeWatch();
274
+ state.scopeWatchCleanup = null;
275
+ });
276
+ }
171
277
  return;
172
278
  }
173
279
  const generation = ++state.requestGeneration;
@@ -214,19 +320,24 @@ export function get<T, Ue = any, Uk = any>(
214
320
  }
215
321
  rebindPublisherConfig();
216
322
  } else {
217
- if (isDynamicPath) {
218
- for (const dependency of endpointDynamicDependencies) {
219
- const unsubscribe = observeDynamicProperty(
220
- getDynamicWatchKeys.watcherStore,
221
- getDynamicWatchKeys.hooked,
222
- component,
223
- dependency,
224
- () => runFetch(),
225
- );
226
- state.cleanupWatchers.push(unsubscribe);
323
+ const startScopedFetch = () => {
324
+ if (isDynamicPath) {
325
+ for (const dependency of endpointDynamicDependencies) {
326
+ const unsubscribe = observeDynamicProperty(
327
+ getDynamicWatchKeys.watcherStore,
328
+ getDynamicWatchKeys.hooked,
329
+ component,
330
+ dependency,
331
+ () => runFetch(),
332
+ );
333
+ state.cleanupWatchers.push(unsubscribe);
334
+ }
227
335
  }
228
- }
229
- runFetch();
336
+ runFetch();
337
+ };
338
+ state.cleanupWatchers.push(
339
+ scheduleAfterHostReady(component, startScopedFetch),
340
+ );
230
341
  }
231
342
  });
232
343
 
@@ -59,8 +59,37 @@ class HTML {
59
59
  return null;
60
60
  }
61
61
 
62
+ /** Noms d'attribut DOM à tester (Lit reflect → minuscules, ex. serviceURL → serviceurl). */
63
+ private static scopeAttributeNames(attributeName: string): string[] {
64
+ const lower = attributeName.toLowerCase();
65
+ return lower === attributeName ? [attributeName] : [attributeName, lower];
66
+ }
67
+
68
+ private static readScopeValueOnElement(
69
+ element: HTMLElement,
70
+ attributeName: string,
71
+ ): string | null {
72
+ // Lit : la propriété est fiable avant le reflect ; un attribut vide (serviceurl="")
73
+ // ne doit pas masquer la propriété.
74
+ const prop = (element as unknown as Record<string, unknown>)[attributeName];
75
+ if (typeof prop === "string" && prop.length > 0) {
76
+ return prop;
77
+ }
78
+ for (const attrName of HTML.scopeAttributeNames(attributeName)) {
79
+ if (element.hasAttribute(attrName)) {
80
+ const attr = element.getAttribute(attrName);
81
+ if (attr != null && attr.length > 0) {
82
+ return attr;
83
+ }
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+
62
89
  /**
63
- * Va de parent en parent en partant de node pour trouver un attribut
90
+ * Va de parent en parent en partant de node pour trouver un attribut.
91
+ * Si l'attribut n'est pas encore reflété (ex. Lit `@property({ reflect: true })`
92
+ * au premier `connectedCallback`), lit la propriété homonyme sur l'élément.
64
93
  * @param attributeName nom de l'attribut
65
94
  * @returns valeur de l'attribut ou null si l'attribut n'est pas trouvé
66
95
  */
@@ -69,16 +98,19 @@ class HTML {
69
98
  attributeName: string
70
99
  ): string | null {
71
100
  if (!node) return null;
72
- while (!("hasAttribute" in node && node.hasAttribute(attributeName))) {
73
- const newNode = node.parentNode || (node as ShadowRoot).host;
74
- if (!newNode) break;
75
- node = (node.parentNode ||
76
- (node as ShadowRoot).host) as SearchableDomElement;
77
- }
78
- if (!("hasAttribute" in node)) {
79
- return null;
101
+ let current: SearchableDomElement | null = node;
102
+ while (current) {
103
+ if (current instanceof HTMLElement) {
104
+ const value = HTML.readScopeValueOnElement(current, attributeName);
105
+ if (value != null && value.length > 0) {
106
+ return value;
107
+ }
108
+ }
109
+ const parent = current.parentNode || (current as ShadowRoot).host;
110
+ if (!parent) break;
111
+ current = parent as SearchableDomElement;
80
112
  }
81
- return node.getAttribute(attributeName);
113
+ return null;
82
114
  }
83
115
 
84
116
  /**