@supersoniks/concorde 4.9.0 → 4.9.2

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 (123) hide show
  1. package/build-infos.json +1 -1
  2. package/concorde-core.bundle.js +195 -195
  3. package/concorde-core.es.js +3390 -3370
  4. package/dist/concorde-core.bundle.js +195 -195
  5. package/dist/concorde-core.es.js +3390 -3370
  6. package/dist/docs-mock-api-sw.js +3 -1
  7. package/dist/docs-mock-api-sw.js.map +2 -2
  8. package/dist/js/core/decorators/api.js +64 -12
  9. package/dist/js/core/decorators/api.js.map +1 -1
  10. package/dist/js/core/utils/HTML.js +4 -5
  11. package/dist/js/core/utils/HTML.js.map +1 -1
  12. package/dist/robots.txt +15 -1
  13. package/dist/types/core/decorators/api.d.ts +14 -1
  14. package/dist/types/core/decorators/api.d.ts.map +1 -1
  15. package/dist/types/core/utils/HTML.d.ts.map +1 -1
  16. package/docs/assets/{index-t0-i22oI.css → index-Bnp7gVaA.css} +1 -1
  17. package/docs/assets/{index-BEgZEIbv.js → index-hElUgV9_.js} +894 -552
  18. package/docs/crawl/core/components/functional/date/date.html +190 -0
  19. package/docs/crawl/core/components/functional/fetch/fetch.html +109 -0
  20. package/docs/crawl/core/components/functional/if/if.html +27 -0
  21. package/docs/crawl/core/components/functional/list/list.html +70 -0
  22. package/docs/crawl/core/components/functional/mix/mix.html +45 -0
  23. package/docs/crawl/core/components/functional/queue/queue.html +96 -0
  24. package/docs/crawl/core/components/functional/router/router.html +106 -0
  25. package/docs/crawl/core/components/functional/sdui/sdui.html +269 -0
  26. package/docs/crawl/core/components/functional/states/states.html +66 -0
  27. package/docs/crawl/core/components/functional/submit/submit.html +160 -0
  28. package/docs/crawl/core/components/functional/subscriber/subscriber.html +67 -0
  29. package/docs/crawl/core/components/functional/value/value.html +51 -0
  30. package/docs/crawl/core/components/ui/alert/alert.html +110 -0
  31. package/docs/crawl/core/components/ui/alert-messages/alert-messages.html +23 -0
  32. package/docs/crawl/core/components/ui/badge/badge.html +119 -0
  33. package/docs/crawl/core/components/ui/button/button.html +149 -0
  34. package/docs/crawl/core/components/ui/captcha/captcha.html +30 -0
  35. package/docs/crawl/core/components/ui/card/card.html +99 -0
  36. package/docs/crawl/core/components/ui/divider/divider.html +58 -0
  37. package/docs/crawl/core/components/ui/form/checkbox/checkbox.html +73 -0
  38. package/docs/crawl/core/components/ui/form/fieldset/fieldset.html +135 -0
  39. package/docs/crawl/core/components/ui/form/form-actions/form-actions.html +79 -0
  40. package/docs/crawl/core/components/ui/form/form-layout/form-layout.html +62 -0
  41. package/docs/crawl/core/components/ui/form/input/input.html +109 -0
  42. package/docs/crawl/core/components/ui/form/input-autocomplete/input-autocomplete.html +106 -0
  43. package/docs/crawl/core/components/ui/form/radio/radio.html +56 -0
  44. package/docs/crawl/core/components/ui/form/select/select.html +73 -0
  45. package/docs/crawl/core/components/ui/form/switch/switch.html +56 -0
  46. package/docs/crawl/core/components/ui/form/textarea/textarea.html +59 -0
  47. package/docs/crawl/core/components/ui/group/group.html +82 -0
  48. package/docs/crawl/core/components/ui/icon/icon.html +133 -0
  49. package/docs/crawl/core/components/ui/image/image.html +110 -0
  50. package/docs/crawl/core/components/ui/link/link.html +42 -0
  51. package/docs/crawl/core/components/ui/loader/loader.html +50 -0
  52. package/docs/crawl/core/components/ui/menu/menu.html +322 -0
  53. package/docs/crawl/core/components/ui/modal/modal.html +117 -0
  54. package/docs/crawl/core/components/ui/pop/pop.html +102 -0
  55. package/docs/crawl/core/components/ui/progress/progress.html +58 -0
  56. package/docs/crawl/core/components/ui/table/table.html +413 -0
  57. package/docs/crawl/core/components/ui/toast/toast.html +122 -0
  58. package/docs/crawl/core/components/ui/tooltip/tooltip.html +79 -0
  59. package/docs/crawl/docs/_core-concept/dataFlow.html +166 -0
  60. package/docs/crawl/docs/_core-concept/overview.html +66 -0
  61. package/docs/crawl/docs/_core-concept/subscriber.html +75 -0
  62. package/docs/crawl/docs/_decorators/ancestor-attribute.html +116 -0
  63. package/docs/crawl/docs/_decorators/auto-subscribe.html +202 -0
  64. package/docs/crawl/docs/_decorators/bind.html +141 -0
  65. package/docs/crawl/docs/_decorators/get.html +120 -0
  66. package/docs/crawl/docs/_decorators/handle.html +165 -0
  67. package/docs/crawl/docs/_decorators/on-assign.html +355 -0
  68. package/docs/crawl/docs/_decorators/patch.html +52 -0
  69. package/docs/crawl/docs/_decorators/post.html +102 -0
  70. package/docs/crawl/docs/_decorators/publish.html +61 -0
  71. package/docs/crawl/docs/_decorators/put.html +49 -0
  72. package/docs/crawl/docs/_decorators/subscribe.html +109 -0
  73. package/docs/crawl/docs/_decorators/wait-for-ancestors.html +137 -0
  74. package/docs/crawl/docs/_directives/sub.html +93 -0
  75. package/docs/crawl/docs/_getting-started/ai-agents.html +134 -0
  76. package/docs/crawl/docs/_getting-started/concorde-manual-install.html +100 -0
  77. package/docs/crawl/docs/_getting-started/concorde-outside.html +58 -0
  78. package/docs/crawl/docs/_getting-started/create-a-component.html +111 -0
  79. package/docs/crawl/docs/_getting-started/my-first-component.html +189 -0
  80. package/docs/crawl/docs/_getting-started/my-first-subscriber.html +110 -0
  81. package/docs/crawl/docs/_getting-started/pubsub.html +62 -0
  82. package/docs/crawl/docs/_getting-started/start.html +76 -0
  83. package/docs/crawl/docs/_getting-started/theming.html +85 -0
  84. package/docs/crawl/docs/_misc/api-configuration.html +149 -0
  85. package/docs/crawl/docs/_misc/dataProviderKey.html +135 -0
  86. package/docs/crawl/docs/_misc/docs-mock-api.html +146 -0
  87. package/docs/crawl/docs/_misc/dynamic-path.html +180 -0
  88. package/docs/crawl/docs/_misc/endpoint.html +50 -0
  89. package/docs/crawl/docs/_misc/html-integration.html +47 -0
  90. package/docs/crawl/hubs/decorators.html +467 -0
  91. package/docs/crawl/hubs/form-components.html +357 -0
  92. package/docs/crawl/hubs/remote-data.html +374 -0
  93. package/docs/crawl/index.html +99 -0
  94. package/docs/docs-mock-api-sw.js +3 -1
  95. package/docs/docs-mock-api-sw.js.map +2 -2
  96. package/docs/index.html +3 -3
  97. package/docs/llms-full.txt +5423 -0
  98. package/docs/llms.txt +102 -0
  99. package/docs/robots.txt +15 -1
  100. package/docs/sitemap.xml +231 -0
  101. package/docs/src/docs/_decorators/ancestor-attribute.md +56 -8
  102. package/docs/src/docs/_decorators/get.md +47 -1
  103. package/docs/src/docs/search/docs-search.json +92 -2
  104. package/docs/src/tsconfig.declarations.json +22 -0
  105. package/docs/src/tsconfig.emit.json +23 -0
  106. package/docs/src/tsconfig.json +3 -285
  107. package/index.html +1 -0
  108. package/package.json +1 -1
  109. package/public/docs-mock-api-sw.js +3 -1
  110. package/public/docs-mock-api-sw.js.map +2 -2
  111. package/public/robots.txt +15 -1
  112. package/scripts/generate-crawl-docs.mjs +569 -0
  113. package/scripts/post-build-docs.js +18 -14
  114. package/src/core/decorators/api.spec.ts +74 -4
  115. package/src/core/decorators/api.ts +103 -20
  116. package/src/core/decorators/subscriber/ancestorAttribute.spec.ts +42 -0
  117. package/src/core/utils/HTML.ts +4 -5
  118. package/src/docs/_decorators/get.md +47 -1
  119. package/src/docs/components/docs-demo-sources.ts +7 -2
  120. package/src/docs/example/decorators-demo-geo.ts +5 -0
  121. package/src/docs/example/decorators-demo-subscribe-publish-get-demos.ts +72 -31
  122. package/src/docs/mock-api/router.ts +3 -1
  123. package/src/docs/search/docs-search.json +92 -2
@@ -22,6 +22,15 @@ async function flushGetMicrotasks() {
22
22
  await Promise.resolve();
23
23
  }
24
24
 
25
+ async function flushGetFrames() {
26
+ await new Promise<void>((resolve) => {
27
+ requestAnimationFrame(() => {
28
+ requestAnimationFrame(() => resolve());
29
+ });
30
+ });
31
+ await Promise.resolve();
32
+ }
33
+
25
34
  function mockPayload<T>(result: T) {
26
35
  return {
27
36
  request: new Request("https://api.example.test/items/1"),
@@ -61,7 +70,7 @@ describe("get", () => {
61
70
  }
62
71
  const el = document.createElement(tag) as InstanceType<typeof C>;
63
72
  document.body.appendChild(el);
64
- await flushGetMicrotasks();
73
+ await flushGetFrames();
65
74
  expect(el.payload?.result).toEqual({ id: "loaded" });
66
75
  expect(el.payload?.response?.ok).toBe(true);
67
76
  expect(el.payload?.request.url).toContain("api.example.test");
@@ -88,7 +97,7 @@ describe("get", () => {
88
97
 
89
98
  const el = document.createElement(tag) as InstanceType<typeof TestRow>;
90
99
  document.body.appendChild(el);
91
- await flushGetMicrotasks();
100
+ await flushGetFrames();
92
101
 
93
102
  expect(API.prototype.getDetailed).toHaveBeenCalledWith("rows/42");
94
103
  expect(el.payload?.result).toEqual({ id: "loaded" });
@@ -136,15 +145,76 @@ describe("get", () => {
136
145
  }
137
146
  const el = document.createElement(tag) as InstanceType<typeof C>;
138
147
  document.body.appendChild(el);
139
- await flushGetMicrotasks();
148
+ await flushGetFrames();
140
149
  expect(API.prototype.getDetailed).toHaveBeenCalledTimes(1);
141
150
 
142
151
  pub.set({
143
152
  ...staticConfig,
144
153
  serviceURL: "https://api.example.test/v2",
145
154
  });
146
- await flushGetMicrotasks();
155
+ await flushGetFrames();
147
156
  expect(API.prototype.getDetailed).toHaveBeenCalledTimes(2);
148
157
  expect(el.payload?.result).toEqual({ id: "loaded" });
149
158
  });
159
+
160
+ it("relance le GET quand le publisher trigger est invalidé", async () => {
161
+ const triggerPubId = "getSpecTrigger1";
162
+ const triggerKey = new DataProviderKey<number>(triggerPubId);
163
+ const itemEp = new Endpoint<{ id: string }>("items/1");
164
+ const tag = "test-api-get-trigger";
165
+
166
+ class C extends HTMLElement {
167
+ @get(itemEp, { triggerKey })
168
+ payload: ApiResult<{ id: string }> | null = null;
169
+ }
170
+ if (!customElements.get(tag)) {
171
+ customElements.define(tag, C);
172
+ }
173
+
174
+ const wrap = document.createElement("div");
175
+ wrap.setAttribute("serviceURL", "https://api.example.test");
176
+ const el = document.createElement(tag) as InstanceType<typeof C>;
177
+ wrap.appendChild(el);
178
+ document.body.appendChild(wrap);
179
+
180
+ await flushGetMicrotasks();
181
+ const callsBeforeInvalidate = vi.mocked(API.prototype.getDetailed).mock
182
+ .calls.length;
183
+ expect(callsBeforeInvalidate).toBeGreaterThanOrEqual(1);
184
+
185
+ PublisherManager.get(triggerPubId).invalidate();
186
+ await flushGetFrames();
187
+ expect(
188
+ vi.mocked(API.prototype.getDetailed).mock.calls.length,
189
+ ).toBeGreaterThan(callsBeforeInvalidate);
190
+ expect(el.payload?.result).toEqual({ id: "loaded" });
191
+ });
192
+
193
+ it("refetch automatique avec refetchEveryMs", async () => {
194
+ vi.useFakeTimers();
195
+ const itemEp = new Endpoint<{ id: string }>("items/1");
196
+ const tag = "test-api-get-refetch";
197
+
198
+ class C extends HTMLElement {
199
+ @get(itemEp, { refetchEveryMs: 1000 })
200
+ payload: ApiResult<{ id: string }> | null = null;
201
+ }
202
+ if (!customElements.get(tag)) {
203
+ customElements.define(tag, C);
204
+ }
205
+
206
+ const wrap = document.createElement("div");
207
+ wrap.setAttribute("serviceURL", "https://api.example.test");
208
+ wrap.appendChild(document.createElement(tag));
209
+ document.body.appendChild(wrap);
210
+
211
+ await flushGetMicrotasks();
212
+ expect(API.prototype.getDetailed).toHaveBeenCalledTimes(1);
213
+
214
+ await vi.advanceTimersByTimeAsync(1000);
215
+ await flushGetMicrotasks();
216
+ expect(API.prototype.getDetailed).toHaveBeenCalledTimes(2);
217
+
218
+ vi.useRealTimers();
219
+ });
150
220
  });
@@ -162,14 +162,25 @@ function watchScopedConfiguration(
162
162
  };
163
163
  }
164
164
 
165
- type ApiGetState = {
166
- cleanupWatchers: Array<() => void>;
167
- requestGeneration: number;
168
- configPublisher: DataProvider | null;
169
- configMutationHandler: (() => void) | null;
170
- scopeWatchCleanup: (() => void) | null;
165
+ type TriggerPublisherBinding = {
166
+ triggerPublisher: DataProvider | null;
167
+ triggerInvalidateHandler: (() => void) | null;
168
+ };
169
+
170
+ type RefetchScheduleState = {
171
+ refetchTimeoutId: ReturnType<typeof setTimeout> | null;
172
+ refetchEveryMs: number;
171
173
  };
172
174
 
175
+ type ApiGetState = TriggerPublisherBinding &
176
+ RefetchScheduleState & {
177
+ cleanupWatchers: Array<() => void>;
178
+ requestGeneration: number;
179
+ configPublisher: DataProvider | null;
180
+ configMutationHandler: (() => void) | null;
181
+ scopeWatchCleanup: (() => void) | null;
182
+ };
183
+
173
184
  function detachConfigPublisher(state: ApiGetState): void {
174
185
  if (state.configPublisher && state.configMutationHandler) {
175
186
  state.configPublisher.offInternalMutation(state.configMutationHandler);
@@ -191,6 +202,9 @@ function detachConfigPublisher(state: ApiGetState): void {
191
202
  * `PublisherManager` sur le chemin résolu (même syntaxe dynamique que `@subscribe`).
192
203
  * Toute mutation interne du publisher (`onInternalMutation`) relance le GET.
193
204
  *
205
+ * **Options** (`GetOptions`) : `triggerKey` — `PublisherManager.get(...).invalidate()` relance le GET ;
206
+ * `refetchEveryMs` — polling automatique.
207
+ *
194
208
  * @example
195
209
  * @get(new Endpoint<User, { userId: string }>("users/${userId}"))
196
210
  * payload?: ApiResult<User>;
@@ -200,6 +214,11 @@ function detachConfigPublisher(state: ApiGetState): void {
200
214
  * PublisherManager.get("myApiConf").set({ serviceURL: "...", token: null, ... });
201
215
  * @get(new Endpoint<Thing, { id: string }>("things/${id}"), apiConf)
202
216
  * payload?: ApiResult<Thing>;
217
+ *
218
+ * @example
219
+ * @get(usersEndpoint, { triggerKey: refreshUsersKey })
220
+ * payload?: ApiResult<User[]>;
221
+ * // PublisherManager.get("users.refresh").invalidate() → refetch
203
222
  */
204
223
  export function get<T, Ue = any>(
205
224
  endpoint: Endpoint<T, Ue>,
@@ -260,18 +279,24 @@ export function get<T, Ue = any, Uk = any, _Ut = any>(
260
279
  skipEmptyPlaceholder: options?.skipEmptyPlaceholder,
261
280
  };
262
281
  const configurationKeyPath = configurationKey?.path;
282
+ const triggerKeyPath = options?.triggerKey?.path;
263
283
  const endpointDynamicDependencies = extractDynamicDependencies(pathTemplate);
264
284
  const configKeyDynamicDependencies = configurationKeyPath
265
285
  ? extractDynamicDependencies(configurationKeyPath)
266
286
  : [];
287
+ const triggerKeyDynamicDependencies = triggerKeyPath
288
+ ? extractDynamicDependencies(triggerKeyPath)
289
+ : [];
267
290
  const mergedDynamicDependencies = [
268
291
  ...new Set([
269
292
  ...endpointDynamicDependencies,
270
293
  ...configKeyDynamicDependencies,
294
+ ...triggerKeyDynamicDependencies,
271
295
  ]),
272
296
  ];
273
297
  const isDynamicPath = endpointDynamicDependencies.length > 0;
274
298
  const usesPublisherConfig = Boolean(configurationKeyPath);
299
+ const refetchEveryMs = options?.refetchEveryMs ?? 0;
275
300
 
276
301
  return function (target: object, propertyKey: string) {
277
302
  if (!target) return;
@@ -288,6 +313,10 @@ export function get<T, Ue = any, Uk = any, _Ut = any>(
288
313
  configPublisher: null,
289
314
  configMutationHandler: null,
290
315
  scopeWatchCleanup: null,
316
+ triggerPublisher: null,
317
+ triggerInvalidateHandler: null,
318
+ refetchTimeoutId: null,
319
+ refetchEveryMs,
291
320
  };
292
321
  comp[stateKey] = state;
293
322
  }
@@ -295,6 +324,17 @@ export function get<T, Ue = any, Uk = any, _Ut = any>(
295
324
  state.cleanupWatchers.forEach((cleanup) => cleanup());
296
325
  state.cleanupWatchers = [];
297
326
  state.requestGeneration++;
327
+ state.refetchEveryMs = refetchEveryMs;
328
+ clearRefetch(state);
329
+
330
+ const scheduleRefetch = () => {
331
+ clearRefetch(state);
332
+ if (!state.refetchEveryMs || state.refetchEveryMs <= 0) return;
333
+ state.refetchTimeoutId = setTimeout(() => {
334
+ state.refetchTimeoutId = null;
335
+ runFetch();
336
+ }, state.refetchEveryMs);
337
+ };
298
338
 
299
339
  const runFetch = () => {
300
340
  const resolution = isDynamicPath
@@ -302,6 +342,7 @@ export function get<T, Ue = any, Uk = any, _Ut = any>(
302
342
  : { ready: true, path: pathTemplate };
303
343
  if (!resolution.ready || !resolution.path) {
304
344
  comp[propertyKey] = undefined;
345
+ scheduleRefetch();
305
346
  return;
306
347
  }
307
348
  let config: APIConfiguration | null = null;
@@ -313,6 +354,7 @@ export function get<T, Ue = any, Uk = any, _Ut = any>(
313
354
  );
314
355
  if (!configRes.ready || !configRes.path) {
315
356
  comp[propertyKey] = undefined;
357
+ scheduleRefetch();
316
358
  return;
317
359
  }
318
360
  const configPublisher = getPublisherFromPath(configRes.path);
@@ -329,6 +371,7 @@ export function get<T, Ue = any, Uk = any, _Ut = any>(
329
371
  state.scopeWatchCleanup = null;
330
372
  });
331
373
  }
374
+ scheduleRefetch();
332
375
  return;
333
376
  }
334
377
  const generation = ++state.requestGeneration;
@@ -338,9 +381,31 @@ export function get<T, Ue = any, Uk = any, _Ut = any>(
338
381
  .then((payload?: ApiResult<T>) => {
339
382
  if (generation !== state.requestGeneration) return;
340
383
  comp[propertyKey] = payload;
384
+ scheduleRefetch();
341
385
  });
342
386
  };
343
387
 
388
+ const scheduleFetch = createAnimationFrameCoalescedRunner(runFetch);
389
+
390
+ const rebindTriggerPublisher = () => {
391
+ detachTriggerPublisher(state);
392
+ if (!triggerKeyPath) return;
393
+ const triggerResolution = resolveDynamicPath(
394
+ component,
395
+ triggerKeyPath,
396
+ pathOptions,
397
+ );
398
+ if (!triggerResolution.ready || !triggerResolution.path) {
399
+ return;
400
+ }
401
+ const publisher = getPublisherFromPath(triggerResolution.path);
402
+ if (!publisher) return;
403
+ const invalidateHandler = () => scheduleFetch();
404
+ publisher.onInvalidate(invalidateHandler);
405
+ state.triggerPublisher = publisher;
406
+ state.triggerInvalidateHandler = invalidateHandler;
407
+ };
408
+
344
409
  const rebindPublisherConfig = () => {
345
410
  if (!usesPublisherConfig || !configurationKeyPath) return;
346
411
  detachConfigPublisher(state);
@@ -358,26 +423,32 @@ export function get<T, Ue = any, Uk = any, _Ut = any>(
358
423
  comp[propertyKey] = undefined;
359
424
  return;
360
425
  }
361
- const mutationHandler = () => {
362
- runFetch();
363
- };
426
+ const mutationHandler = () => scheduleFetch();
364
427
  publisher.onInternalMutation(mutationHandler);
365
428
  state.configPublisher = publisher;
366
429
  state.configMutationHandler = mutationHandler;
367
430
  };
368
431
 
369
- if (usesPublisherConfig) {
432
+ const rebindAll = () => {
433
+ rebindTriggerPublisher();
434
+ rebindPublisherConfig();
435
+ if (!usesPublisherConfig) {
436
+ scheduleFetch();
437
+ }
438
+ };
439
+
440
+ if (usesPublisherConfig || triggerKeyDynamicDependencies.length) {
370
441
  for (const dependency of mergedDynamicDependencies) {
371
442
  const unsubscribe = observeDynamicProperty(
372
443
  getDynamicWatchKeys.watcherStore,
373
444
  getDynamicWatchKeys.hooked,
374
445
  component,
375
446
  dependency,
376
- () => rebindPublisherConfig(),
447
+ () => rebindAll(),
377
448
  );
378
449
  state.cleanupWatchers.push(unsubscribe);
379
450
  }
380
- rebindPublisherConfig();
451
+ rebindAll();
381
452
  } else {
382
453
  const startScopedFetch = () => {
383
454
  if (isDynamicPath) {
@@ -387,11 +458,12 @@ export function get<T, Ue = any, Uk = any, _Ut = any>(
387
458
  getDynamicWatchKeys.hooked,
388
459
  component,
389
460
  dependency,
390
- () => runFetch(),
461
+ () => scheduleFetch(),
391
462
  );
392
463
  state.cleanupWatchers.push(unsubscribe);
393
464
  }
394
465
  }
466
+ rebindTriggerPublisher();
395
467
  runFetch();
396
468
  };
397
469
  state.cleanupWatchers.push(
@@ -405,6 +477,8 @@ export function get<T, Ue = any, Uk = any, _Ut = any>(
405
477
  const state = comp[stateKey] as ApiGetState | undefined;
406
478
  if (!state) return;
407
479
  detachConfigPublisher(state);
480
+ detachTriggerPublisher(state);
481
+ clearRefetch(state);
408
482
  state.cleanupWatchers.forEach((cleanup) => cleanup());
409
483
  state.cleanupWatchers = [];
410
484
  state.requestGeneration++;
@@ -413,7 +487,12 @@ export function get<T, Ue = any, Uk = any, _Ut = any>(
413
487
  };
414
488
  }
415
489
 
416
- export type GetOptions = DynamicPathOptions;
490
+ export type GetOptions<Uk = any> = DynamicPathOptions & {
491
+ /** Intervalle de refetch automatique (ms). 0 ou absent = pas de polling. */
492
+ refetchEveryMs?: number;
493
+ /** Publisher dont `invalidate()` relance le GET. */
494
+ triggerKey?: DataProviderKey<unknown, Uk>;
495
+ };
417
496
 
418
497
  export type ApiSendOptions<Uk = any> = DynamicPathOptions & {
419
498
  /** Intervalle de renvoi automatique (ms). 0 ou absent = pas de polling. */
@@ -436,7 +515,11 @@ export type PatchOptions<Uk = any> = ApiSendOptions<Uk>;
436
515
 
437
516
  function isGetOptions(value: unknown): value is GetOptions {
438
517
  if (!value || typeof value !== "object") return false;
439
- return "skipEmptyPlaceholder" in value;
518
+ return (
519
+ "skipEmptyPlaceholder" in value ||
520
+ "refetchEveryMs" in value ||
521
+ "triggerKey" in value
522
+ );
440
523
  }
441
524
 
442
525
  function isApiSendOptions(value: unknown): value is ApiSendOptions {
@@ -469,7 +552,7 @@ function detachBodyPublisher(state: ApiSendState): void {
469
552
  state.bodyMutationHandler = null;
470
553
  }
471
554
 
472
- function detachTriggerPublisher(state: ApiSendState): void {
555
+ function detachTriggerPublisher(state: TriggerPublisherBinding): void {
473
556
  if (state.triggerPublisher && state.triggerInvalidateHandler) {
474
557
  state.triggerPublisher.offInvalidate(state.triggerInvalidateHandler);
475
558
  }
@@ -477,7 +560,7 @@ function detachTriggerPublisher(state: ApiSendState): void {
477
560
  state.triggerInvalidateHandler = null;
478
561
  }
479
562
 
480
- function clearSendRefetch(state: ApiSendState): void {
563
+ function clearRefetch(state: RefetchScheduleState): void {
481
564
  if (state.refetchTimeoutId) {
482
565
  clearTimeout(state.refetchTimeoutId);
483
566
  state.refetchTimeoutId = null;
@@ -582,10 +665,10 @@ function createApiSendDecorator<T, B, Ue = any, Uk = any, Ut = any>(
582
665
  state.refetchEveryMs = refetchEveryMs;
583
666
  state.skipIfBodyMissing = skipIfBodyMissing;
584
667
  state.autoPostOnBodyMutation = autoPostOnBodyMutation;
585
- clearSendRefetch(state);
668
+ clearRefetch(state);
586
669
 
587
670
  const scheduleRefetch = () => {
588
- clearSendRefetch(state);
671
+ clearRefetch(state);
589
672
  if (!state.refetchEveryMs || state.refetchEveryMs <= 0) return;
590
673
  state.refetchTimeoutId = setTimeout(() => {
591
674
  state.refetchTimeoutId = null;
@@ -789,7 +872,7 @@ function createApiSendDecorator<T, B, Ue = any, Uk = any, Ut = any>(
789
872
  detachConfigPublisher(state);
790
873
  detachBodyPublisher(state);
791
874
  detachTriggerPublisher(state);
792
- clearSendRefetch(state);
875
+ clearRefetch(state);
793
876
  state.cleanupWatchers.forEach((cleanup) => cleanup());
794
877
  state.cleanupWatchers = [];
795
878
  state.requestGeneration++;
@@ -55,6 +55,25 @@ describe("ancestorAttribute", () => {
55
55
  expect(el.dataProvider).toBe("scope-a");
56
56
  });
57
57
 
58
+ it("assigne une chaine vide si l'attribut ancêtre est présent sans valeur (mode statique)", () => {
59
+ const tag = "test-ancestor-attr-static-empty";
60
+ class C extends HTMLElement {
61
+ @ancestorAttribute("is_guichet")
62
+ isGuichet: string | null = null;
63
+ }
64
+ if (!customElements.get(tag)) {
65
+ customElements.define(tag, C);
66
+ }
67
+
68
+ const wrap = document.createElement("div");
69
+ wrap.setAttribute("is_guichet", "");
70
+ const el = document.createElement(tag) as InstanceType<typeof C>;
71
+ wrap.appendChild(el);
72
+ document.body.appendChild(wrap);
73
+
74
+ expect(el.isGuichet).toBe("");
75
+ });
76
+
58
77
  it("met à jour la propriété quand un ancêtre change (mode dynamique)", async () => {
59
78
  const tag = "test-ancestor-attr-dynamic-mutation";
60
79
  class C extends HTMLElement {
@@ -101,6 +120,29 @@ describe("ancestorAttribute", () => {
101
120
  expect(el.dataProvider).toBeNull();
102
121
  });
103
122
 
123
+ it("met à jour avec une chaine vide si l'attribut ancêtre devient présent sans valeur (mode dynamique)", async () => {
124
+ const tag = "test-ancestor-attr-dynamic-empty";
125
+ class C extends HTMLElement {
126
+ @ancestorAttribute("is_guichet", { dynamic: true })
127
+ isGuichet: string | null = null;
128
+ }
129
+ if (!customElements.get(tag)) {
130
+ customElements.define(tag, C);
131
+ }
132
+
133
+ const wrap = document.createElement("div");
134
+ const el = document.createElement(tag) as InstanceType<typeof C>;
135
+ wrap.appendChild(el);
136
+ document.body.appendChild(wrap);
137
+
138
+ expect(el.isGuichet).toBeNull();
139
+
140
+ wrap.setAttribute("is_guichet", "");
141
+ await flushFrames();
142
+
143
+ expect(el.isGuichet).toBe("");
144
+ });
145
+
104
146
  it("retombe sur l'ancêtre suivant quand le plus proche perd l'attribut", async () => {
105
147
  const tag = "test-ancestor-attr-dynamic-fallback";
106
148
  class C extends HTMLElement {
@@ -71,10 +71,9 @@ class HTML {
71
71
  ): string | null {
72
72
  for (const attrName of HTML.scopeAttributeNames(attributeName)) {
73
73
  if (element.hasAttribute(attrName)) {
74
- const attr = element.getAttribute(attrName);
75
- if (attr != null && attr.length > 0) {
76
- return attr;
77
- }
74
+ // Un attribut HTML peut exister sans valeur (ex: <x is_guichet>),
75
+ // on retourne donc "" plutôt que null pour préserver ce cas.
76
+ return element.getAttribute(attrName) ?? "";
78
77
  }
79
78
  }
80
79
  return null;
@@ -94,7 +93,7 @@ class HTML {
94
93
  while (current) {
95
94
  if (current instanceof HTMLElement) {
96
95
  const value = HTML.readScopeAttributeOnElement(current, attributeName);
97
- if (value != null && value.length > 0) {
96
+ if (value != null) {
98
97
  return value;
99
98
  }
100
99
  }
@@ -13,10 +13,53 @@ Pass an [`Endpoint<T>`](#docs/_misc/endpoint.md/endpoint) as the first argument.
13
13
 
14
14
  `${prop}` on the endpoint or config key is resolved on the host. While a placeholder is `null` or `undefined`, no GET runs. Optional `skipEmptyPlaceholder: true` also blocks `""` (empty string only). Details: [Dynamic path placeholders](#docs/_misc/dynamic-path.md/dynamic-path).
15
15
 
16
+ ## Options (`GetOptions`)
17
+
18
+ Pass as the **second** argument (scoped config) or **third** (with a configuration key). Same shape as send decorators where applicable:
19
+
20
+ | Option | Description |
21
+ |--------|-------------|
22
+ | `skipEmptyPlaceholder` | Treat `""` as not ready (see [Dynamic path](#docs/_misc/dynamic-path.md/dynamic-path)). |
23
+ | `refetchEveryMs` | Automatic polling interval (ms). `0` or omitted = no polling. |
24
+ | `triggerKey` | `DataProviderKey` — `PublisherManager.get(...).invalidate()` on that publisher re-runs the GET. |
25
+
26
+ ### Manual refetch (`triggerKey`)
27
+
28
+ Same pattern as [@post](#docs/_decorators/post.md/post): a publisher acts as a **signal**, independent of the response payload.
29
+
30
+ ```typescript
31
+ import { get, type ApiResult } from "@supersoniks/concorde/decorators";
32
+ import { Endpoint } from "@supersoniks/concorde/utils/endpoint";
33
+ import { DataProviderKey } from "@supersoniks/concorde/dataProviderKey";
34
+ import { dp } from "@supersoniks/concorde/core/utils/PublisherProxy";
35
+
36
+ const geoCommunesEndpoint = new Endpoint<GeoCommuneRow[]>(
37
+ "communes?limit=5&fields=nom,code",
38
+ );
39
+ const geoCommunesRefreshKey = new DataProviderKey<void>(
40
+ "docsDemoGeoCommunesRefresh",
41
+ );
42
+
43
+ @get(geoCommunesEndpoint, apiConfigurationKey, {
44
+ triggerKey: geoCommunesRefreshKey,
45
+ })
46
+ @state()
47
+ payload: ApiResult<GeoCommuneRow[]> | null = null;
48
+
49
+ // Same component or anywhere else:
50
+ dp(geoCommunesRefreshKey).invalidate();
51
+ ```
52
+
53
+ Prefer `triggerKey` over mutating the API configuration publisher when you only want to **reload the same URL** — no need to touch `serviceURL`, tokens, etc.
54
+
55
+ The docs mock adds an **`X-Fetched-At`** response header on `/docs-mock-api/geo/communes` so live demos can show when the last fetch happened (`response.headers.get("X-Fetched-At")`).
56
+
16
57
  ## When the GET runs again
17
58
 
18
59
  - A referenced Lit property changes (endpoint path and/or config key contains `${...}`).
19
60
  - `set` on the active configuration publisher (`onInternalMutation`).
61
+ - `triggerKey` publisher `invalidate()`.
62
+ - `refetchEveryMs` polling (after each successful fetch).
20
63
 
21
64
  ## Import
22
65
 
@@ -42,6 +85,8 @@ payload: ApiResult<User> | null = null;
42
85
 
43
86
  ## Live demos
44
87
 
88
+ `@get` with **`triggerKey`** — refresh button + `X-Fetched-At` timestamp in the UI:
89
+
45
90
  <sonic-code>
46
91
  <template>
47
92
  <docs-demo-sources for="demo-api-get"></docs-demo-sources>
@@ -49,12 +94,13 @@ payload: ApiResult<User> | null = null;
49
94
  </template>
50
95
  </sonic-code>
51
96
 
52
- Dynamic config and endpoint path (`demo-api-get-configuration-key` in doc sources):
97
+ Dynamic config and endpoint path. Manual refetch is triggered **from a separate component** (same `triggerKey` publisher):
53
98
 
54
99
  <sonic-code>
55
100
  <template>
56
101
  <docs-demo-sources for="demo-api-get-configuration-key"></docs-demo-sources>
57
102
  <demo-api-get-configuration-key></demo-api-get-configuration-key>
103
+ <demo-api-get-refresh-remote></demo-api-get-refresh-remote>
58
104
  </template>
59
105
  </sonic-code>
60
106
 
@@ -255,10 +255,15 @@ export const DOCS_DEMO_SOURCE_REGISTRY: Record<string, DocsSource[]> = {
255
255
  "demo-api-get-configuration-key": [
256
256
  {
257
257
  path: "src/docs/example/decorators-demo-subscribe-publish-get-demos.ts",
258
- line: 126,
258
+ line: 147,
259
259
  label: "demo-api-get-configuration-key",
260
260
  },
261
- { path: "src/docs/example/decorators-demo-geo.ts", label: "geo API" },
261
+ {
262
+ path: "src/docs/example/decorators-demo-subscribe-publish-get-demos.ts",
263
+ line: 239,
264
+ label: "demo-api-get-refresh-remote",
265
+ },
266
+ { path: "src/docs/example/decorators-demo-geo.ts", label: "geo API + triggerKey" },
262
267
  ],
263
268
  "demo-api-get-publish-subscribe": [
264
269
  {
@@ -49,3 +49,8 @@ export const geoCommunesApiGetEndpointDynamic = new Endpoint<GeoCommuneRow[]>(
49
49
  export const geoCommunesApiGetPublishKey = new DataProviderKey<
50
50
  ApiResult<GeoCommuneRow[]>
51
51
  >(geoCommunesApiGetEndpoint.path);
52
+
53
+ /** Signal de refetch manuel pour les démos `@get` (voir `triggerKey`). */
54
+ export const docsDemoGeoCommunesRefreshKey = new DataProviderKey<void>(
55
+ "docsDemoGeoCommunesRefresh",
56
+ );