@cosmicdrift/kumiko-renderer-web 0.197.1 → 0.199.0

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": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.197.1",
3
+ "version": "0.199.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.197.1",
20
- "@cosmicdrift/kumiko-headless": "0.197.1",
21
- "@cosmicdrift/kumiko-renderer": "0.197.1",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.199.0",
20
+ "@cosmicdrift/kumiko-headless": "0.199.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.199.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -206,6 +206,99 @@ describe("createKumikoApp", () => {
206
206
  );
207
207
  });
208
208
 
209
+ test("custom screen without a registered clientFeatures component → console.error names feature + screen at boot (kumiko-framework#2025)", async () => {
210
+ // A server feature can declare a `type: "custom"` screen without the
211
+ // app mounting the corresponding client plugin — the screen is still
212
+ // reachable via URL. createKumikoApp should surface that visibly ONCE
213
+ // at boot instead of only showing it as a banner when the URL is
214
+ // opened (which nobody would see if they never visit the route).
215
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
216
+
217
+ const customScreenSchema: FeatureSchema = {
218
+ featureName: "delivery",
219
+ entities: { task: taskEntity },
220
+ screens: [
221
+ {
222
+ id: "delivery-log",
223
+ type: "custom",
224
+ renderer: { react: { __component: "DeliveryLog" } },
225
+ },
226
+ ],
227
+ };
228
+
229
+ mountRoot();
230
+ await mountApp({
231
+ schema: customScreenSchema,
232
+ dispatcher: makeDispatcher(),
233
+ screenQn: "delivery:screen:delivery-log",
234
+ });
235
+
236
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("delivery:delivery-log"));
237
+
238
+ errorSpy.mockRestore();
239
+ });
240
+
241
+ test("custom screen WITH a registered clientFeatures component → no boot diagnostic", async () => {
242
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
243
+
244
+ function DeliveryLog(): ReactNode {
245
+ return <span data-testid="delivery-log-mounted" />;
246
+ }
247
+ const customScreenSchema: FeatureSchema = {
248
+ featureName: "delivery",
249
+ entities: { task: taskEntity },
250
+ screens: [
251
+ {
252
+ id: "delivery-log",
253
+ type: "custom",
254
+ renderer: { react: { __component: "DeliveryLog" } },
255
+ },
256
+ ],
257
+ };
258
+
259
+ mountRoot();
260
+ await mountApp({
261
+ schema: customScreenSchema,
262
+ dispatcher: makeDispatcher(),
263
+ screenQn: "delivery:screen:delivery-log",
264
+ clientFeatures: [{ name: "delivery", components: { "delivery-log": DeliveryLog } }],
265
+ });
266
+
267
+ expect(await screen.findByTestId("delivery-log-mounted")).toBeTruthy();
268
+ expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining("delivery:delivery-log"));
269
+
270
+ errorSpy.mockRestore();
271
+ });
272
+
273
+ test("dormant custom screen without a registered clientFeatures component → no boot diagnostic (kumiko-framework#2034)", async () => {
274
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
275
+
276
+ const dormantScreenSchema: FeatureSchema = {
277
+ featureName: "user-data-rights",
278
+ entities: { task: taskEntity },
279
+ screens: [
280
+ {
281
+ id: "privacy-center",
282
+ type: "custom",
283
+ renderer: { react: { __component: "PrivacyCenterScreen" } },
284
+ access: { openToAll: true },
285
+ dormant: true,
286
+ },
287
+ ],
288
+ };
289
+
290
+ mountRoot();
291
+ await mountApp({
292
+ schema: dormantScreenSchema,
293
+ dispatcher: makeDispatcher(),
294
+ screenQn: "user-data-rights:screen:privacy-center",
295
+ });
296
+
297
+ expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining("privacy-center"));
298
+
299
+ errorSpy.mockRestore();
300
+ });
301
+
209
302
  test("clientFeatures.columnRenderers → bei Key-Kollision warnt + last-wins gewinnt", async () => {
210
303
  // Zwei Features liefern denselben Renderer-Key — der Merge in
211
304
  // create-app warnt und behält den späteren Eintrag (Last-Wins).
@@ -150,6 +150,84 @@ describe("KumikoScreen", () => {
150
150
  expect(seenTypes).toEqual(["tasks:query:task:list"]);
151
151
  });
152
152
 
153
+ // #2062: the search box is a dead toolbar slot (and a guaranteed 422,
154
+ // #2032) once the client knows the server has no SearchAdapter wired.
155
+ describe("entityList search box gated on schema.searchAdapterMissing (#2062)", () => {
156
+ const searchableWidgetEntity = {
157
+ fields: { title: { type: "text", required: true, searchable: true } },
158
+ } as unknown as EntityDefinition;
159
+ const autoSearchScreen: EntityListScreenDefinition = {
160
+ id: "widget-list",
161
+ type: "entityList",
162
+ entity: "widget",
163
+ columns: ["title"],
164
+ };
165
+ const explicitSearchScreen: EntityListScreenDefinition = {
166
+ id: "widget-list-explicit",
167
+ type: "entityList",
168
+ entity: "widget",
169
+ columns: ["title"],
170
+ searchable: true,
171
+ };
172
+ const explicitNoSearchScreen: EntityListScreenDefinition = {
173
+ id: "widget-list-no-search",
174
+ type: "entityList",
175
+ entity: "widget",
176
+ columns: ["title"],
177
+ searchable: false,
178
+ };
179
+
180
+ function buildWidgetSchema(
181
+ screenDef: EntityListScreenDefinition,
182
+ searchAdapterMissing?: boolean,
183
+ ): FeatureSchema {
184
+ return {
185
+ featureName: "widgets",
186
+ entities: { widget: searchableWidgetEntity },
187
+ screens: [screenDef],
188
+ ...(searchAdapterMissing !== undefined && { searchAdapterMissing }),
189
+ };
190
+ }
191
+
192
+ async function renderWidgetList(schemaDef: FeatureSchema, qn: string): Promise<void> {
193
+ render(
194
+ <DispatcherProvider dispatcher={makeDispatcher()}>
195
+ <KumikoScreen schema={schemaDef} qn={qn} />
196
+ </DispatcherProvider>,
197
+ );
198
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
199
+ }
200
+
201
+ test("auto-detected searchable field renders the search box when the flag is absent", async () => {
202
+ await renderWidgetList(buildWidgetSchema(autoSearchScreen), "widgets:screen:widget-list");
203
+ expect(document.querySelector("#render-list-search")).not.toBeNull();
204
+ });
205
+
206
+ test("searchAdapterMissing: true hides the search box despite an auto-detected searchable field", async () => {
207
+ await renderWidgetList(
208
+ buildWidgetSchema(autoSearchScreen, true),
209
+ "widgets:screen:widget-list",
210
+ );
211
+ expect(document.querySelector("#render-list-search")).toBeNull();
212
+ });
213
+
214
+ test("searchAdapterMissing: true hides the search box even when screen.searchable is explicitly true", async () => {
215
+ await renderWidgetList(
216
+ buildWidgetSchema(explicitSearchScreen, true),
217
+ "widgets:screen:widget-list-explicit",
218
+ );
219
+ expect(document.querySelector("#render-list-search")).toBeNull();
220
+ });
221
+
222
+ test("screen.searchable: false stays hidden regardless of searchAdapterMissing", async () => {
223
+ await renderWidgetList(
224
+ buildWidgetSchema(explicitNoSearchScreen, false),
225
+ "widgets:screen:widget-list-no-search",
226
+ );
227
+ expect(document.querySelector("#render-list-search")).toBeNull();
228
+ });
229
+ });
230
+
153
231
  test("entityEdit with unknown entity on the screen → entity-missing placeholder", () => {
154
232
  const brokenScreen: EntityEditScreenDefinition = {
155
233
  id: "broken",
@@ -1707,7 +1785,7 @@ describe("KumikoScreen", () => {
1707
1785
  expect(navigateCalls).toEqual([]);
1708
1786
  });
1709
1787
 
1710
- test("custom screen type → placeholder (M4 wires r.uiComponent)", () => {
1788
+ test("custom screen type without a registered component error placeholder naming feature + screen (kumiko-framework#2025)", () => {
1711
1789
  const customSchema: FeatureSchema = {
1712
1790
  featureName: "tasks",
1713
1791
  entities: { task: taskEntity },
@@ -1724,7 +1802,11 @@ describe("KumikoScreen", () => {
1724
1802
  <KumikoScreen schema={customSchema} qn="tasks:screen:dashboard" />
1725
1803
  </DispatcherProvider>,
1726
1804
  );
1727
- expect(screen.getByTestId("kumiko-screen-custom-placeholder")).toBeTruthy();
1805
+ const placeholder = screen.getByTestId("kumiko-screen-custom-placeholder");
1806
+ expect(placeholder.getAttribute("data-variant")).toBe("error");
1807
+ expect(placeholder.textContent).toContain("dashboard");
1808
+ expect(placeholder.textContent).toContain("tasks");
1809
+ expect(placeholder.textContent).toContain("clientFeatures");
1728
1810
  });
1729
1811
 
1730
1812
  // ------------------------------------------------------------------
@@ -2015,6 +2097,79 @@ describe("KumikoScreen: singleton entityEdit", () => {
2015
2097
  expect(bannerText).toBe(kumikoDefaultTranslations["en"]?.["errors.access.denied"] ?? "");
2016
2098
  expect(screen.queryByTestId("render-edit-form")).toBeNull();
2017
2099
  });
2100
+
2101
+ // kumiko-screen#1944: EntityEditSingletonBody runs without a wrapping
2102
+ // entityList screen, so the create-body's default "navigate back to the
2103
+ // list" success handler is a silent no-op — a successful create used to
2104
+ // leave the form stuck on the just-submitted create values, and a second
2105
+ // submit created a duplicate record ("exactly one record per tenant"
2106
+ // broken). Create on an empty table must now switch straight to the
2107
+ // update form of the newly created record.
2108
+ test("erfolgreicher Create bei leerer Tabelle wechselt in Update-Form des neuen Records (kein zweiter Create möglich)", async () => {
2109
+ let created = false;
2110
+ const write = mock(async (type: string) => {
2111
+ if (type === "tasks:write:task:create") {
2112
+ created = true;
2113
+ return { isSuccess: true, data: { id: "task-1" } };
2114
+ }
2115
+ return { isSuccess: true, data: {} };
2116
+ });
2117
+ const query = mock(async (type: string) => {
2118
+ if (type === "tasks:query:task:list") {
2119
+ return created
2120
+ ? { isSuccess: true, data: { rows: [{ id: "task-1", title: "Created title" }] } }
2121
+ : { isSuccess: true, data: { rows: [], nextCursor: null } };
2122
+ }
2123
+ if (type === "tasks:query:task:detail") {
2124
+ return {
2125
+ isSuccess: true,
2126
+ data: { id: "task-1", version: 1, title: "Created title", count: 0, done: false },
2127
+ };
2128
+ }
2129
+ return { isSuccess: true, data: { rows: [], nextCursor: null } };
2130
+ });
2131
+ const dispatcher = makeDispatcher({
2132
+ write: write as unknown as Dispatcher["write"],
2133
+ query: query as unknown as Dispatcher["query"],
2134
+ });
2135
+
2136
+ render(
2137
+ <DispatcherProvider dispatcher={dispatcher}>
2138
+ <KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
2139
+ </DispatcherProvider>,
2140
+ );
2141
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
2142
+
2143
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
2144
+ fireEvent.change(titleInput, { target: { value: "Created title" } });
2145
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
2146
+
2147
+ await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
2148
+ // The singleton wrapper refetches its list(limit:1) query after the
2149
+ // create succeeds, sees the new row, and switches from the create body
2150
+ // to the update body — which loads the record via its own detail
2151
+ // query, landing on the same title through a different data path.
2152
+ await waitFor(() => {
2153
+ const input = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
2154
+ expect(input.value).toBe("Created title");
2155
+ });
2156
+ expect(query).toHaveBeenCalledWith(
2157
+ "tasks:query:task:detail",
2158
+ { id: "task-1" },
2159
+ expect.anything(),
2160
+ );
2161
+
2162
+ // A second submit on the (now update-mode) form must dispatch an
2163
+ // update, never a second create.
2164
+ fireEvent.change(screen.getByTestId("field-title").querySelector("input") as HTMLInputElement, {
2165
+ target: { value: "Edited again" },
2166
+ });
2167
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
2168
+ // Exactly one create (the first submit) followed by an update, never a
2169
+ // second create — the singleton wrapper must have flipped branches.
2170
+ await waitFor(() => expect(write).toHaveBeenCalledTimes(2));
2171
+ expect(write).toHaveBeenLastCalledWith("tasks:write:task:update", expect.anything());
2172
+ });
2018
2173
  });
2019
2174
 
2020
2175
  // --- actionForm extension-section (Wave J: Incident-Update-Timeline) ---
@@ -5,8 +5,18 @@
5
5
  // 2. Active-State greift auf node mit screen wenn nav.route's
6
6
  // screenId matcht (Standard-Sidebar-Verhalten).
7
7
 
8
- import { afterEach, describe, expect, test } from "bun:test";
8
+ import {
9
+ afterAll,
10
+ afterEach,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ type Mock,
15
+ spyOn,
16
+ test,
17
+ } from "bun:test";
9
18
  import type {
19
+ NavIconKey,
10
20
  TargetRef,
11
21
  TreeChildrenSubscribe,
12
22
  TreeNode,
@@ -169,6 +179,18 @@ function expectNavIcons(container: HTMLElement, iconKeys: readonly string[]): vo
169
179
  }
170
180
 
171
181
  describe("NavTree", () => {
182
+ let warnSpy: Mock<typeof console.warn>;
183
+ beforeEach(() => {
184
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
185
+ // spyOn reuses the same underlying mock across calls on the same target
186
+ // within a process — without an explicit clear, call history from a
187
+ // PRIOR test in this describe block leaks into the next test's spy.
188
+ warnSpy.mockClear();
189
+ });
190
+ afterAll(() => {
191
+ warnSpy.mockRestore();
192
+ });
193
+
172
194
  test("Section-Header (parent ohne screen) ist statisches Label, children sichtbar", () => {
173
195
  render(<NavTree schema={makeSchema()} testId="tree" />);
174
196
 
@@ -318,15 +340,50 @@ describe("NavTree", () => {
318
340
  expectNavIcons(container, ["palette", "link", "share"]);
319
341
  });
320
342
 
321
- test("unbekannter icon-Key fällt sauber auf den Dot zurück (kein svg)", () => {
343
+ test("unbekannter icon-Key fällt sauber auf den Dot zurück (kein svg), aber warnt sichtbar", () => {
322
344
  const schema = {
323
345
  featureName: "showcase",
324
346
  entities: {},
325
347
  screens: [{ id: "x", type: "entityList", entity: "x", columns: [] }],
326
- navs: [{ id: "x", label: "X", screen: "x", order: 10, icon: "does-not-exist" }],
348
+ navs: [
349
+ {
350
+ id: "x",
351
+ label: "X",
352
+ screen: "x",
353
+ order: 10,
354
+ // Deliberately unregistered — asserts the runtime fallback, not the
355
+ // (now compile-time) NavIconKey vocabulary.
356
+ icon: "does-not-exist" as NavIconKey,
357
+ },
358
+ ],
327
359
  } as FeatureSchema;
328
360
  const { container } = render(<NavTree schema={schema} />);
329
361
  expect(container.querySelectorAll("svg").length).toBe(0);
362
+ expect(warnSpy).toHaveBeenCalledWith(
363
+ expect.stringContaining('Nav entry "showcase:nav:x" references icon "does-not-exist"'),
364
+ );
365
+ });
366
+
367
+ test("bekannter icon-Key warnt NICHT", () => {
368
+ const schema = {
369
+ featureName: "showcase",
370
+ entities: {},
371
+ screens: [{ id: "dash", type: "entityList", entity: "x", columns: [] }],
372
+ navs: [{ id: "dash", label: "Dash", screen: "dash", order: 10, icon: "dashboard" }],
373
+ } as FeatureSchema;
374
+ render(<NavTree schema={schema} />);
375
+ expect(warnSpy).not.toHaveBeenCalled();
376
+ });
377
+
378
+ test("kein icon gesetzt warnt NICHT (Bundled Features ohne Icon sind kein Fehler)", () => {
379
+ const schema = {
380
+ featureName: "showcase",
381
+ entities: {},
382
+ screens: [{ id: "plain", type: "entityList", entity: "x", columns: [] }],
383
+ navs: [{ id: "plain", label: "Plain", screen: "plain", order: 10 }],
384
+ } as FeatureSchema;
385
+ render(<NavTree schema={schema} />);
386
+ expect(warnSpy).not.toHaveBeenCalled();
330
387
  });
331
388
  });
332
389
 
@@ -2512,6 +2512,13 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
2512
2512
  // No silent adopt: the form stays pristine until the user picks.
2513
2513
  const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
2514
2514
  expect(titleInput.value).toBe("");
2515
+ // #1976: single-candidate is now the normal case (auto-adopt removed) —
2516
+ // the banner text and the "start new" button must both resolve to real
2517
+ // copy, not the raw i18n keys.
2518
+ const picker = screen.getByTestId("render-edit-draft-picker");
2519
+ expect(picker.textContent).toContain("Found an open draft for this form");
2520
+ expect(picker.textContent).not.toContain("kumiko.form.draft.resume-single");
2521
+ expect(screen.getByTestId("render-edit-draft-start-new").textContent).toBe("Start new");
2515
2522
  });
2516
2523
 
2517
2524
  test("start-new on a one-candidate picker clears it without adopting, leaving the candidate untouched", async () => {
@@ -2587,6 +2594,10 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
2587
2594
  );
2588
2595
 
2589
2596
  await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
2597
+ // Multiple candidates keep the plural banner copy.
2598
+ expect(screen.getByTestId("render-edit-draft-picker").textContent).toContain(
2599
+ "Found multiple open drafts",
2600
+ );
2590
2601
  const pickSecond = screen.getByTestId(`render-edit-draft-pick-${screenDef.id}:new:draft-2`);
2591
2602
  fireEvent.click(pickSecond);
2592
2603
 
@@ -3197,4 +3208,37 @@ describe("RenderEdit fields filter", () => {
3197
3208
  expect(write).not.toHaveBeenCalled();
3198
3209
  expect(screen.getByTestId("field-notes-errors")).toBeTruthy();
3199
3210
  });
3211
+
3212
+ // #1907: `fields` naming only unknown field names (typo, renamed field)
3213
+ // must not collapse the validation scope to an empty array — an empty
3214
+ // scope filters out ALL issues and lets submit through unvalidated.
3215
+ test("fields naming only unknown field names falls back to unscoped and still blocks submit", async () => {
3216
+ const write = mock(async () => ({ isSuccess: true, data: { id: "1" } }) as never);
3217
+ const schema = z.object({
3218
+ title: z.string().min(1),
3219
+ notes: z.string().min(1),
3220
+ });
3221
+
3222
+ render(
3223
+ <DispatcherProvider dispatcher={makeDispatcher(write)}>
3224
+ <RenderEdit<FilterValues>
3225
+ screen={makeTwoSectionScreen()}
3226
+ entity={filterEntity}
3227
+ featureName="orders"
3228
+ initial={{ title: "Acme", count: 0, notes: "" }}
3229
+ writeCommand="order:create"
3230
+ schema={schema}
3231
+ fields={["doesNotExist"]}
3232
+ />
3233
+ </DispatcherProvider>,
3234
+ );
3235
+
3236
+ const form = screen.getByTestId("render-edit-form");
3237
+ await act(async () => {
3238
+ fireEvent.submit(form);
3239
+ await Promise.resolve();
3240
+ });
3241
+
3242
+ expect(write).not.toHaveBeenCalled();
3243
+ });
3200
3244
  });
@@ -332,6 +332,35 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
332
332
  for (const f of clientFeatures) {
333
333
  if (f.components !== undefined) Object.assign(customScreens, f.components);
334
334
  }
335
+ // Fail loud at boot instead of per-URL: a mounted server feature can
336
+ // declare a `type: "custom"` screen that's reachable directly by URL
337
+ // even without nav placement — without this check, a missing client
338
+ // plugin only surfaces as an error placeholder to whoever happens to
339
+ // open that URL (kumiko-framework#2025). Screens flagged `dormant`
340
+ // (e.g. user-data-rights privacy-center, auth-mfa's enable screen) are
341
+ // registered without a self-owned r.nav on purpose — an app opts in by
342
+ // navving them explicitly, so a consumer that hasn't done that yet isn't
343
+ // missing anything (kumiko-framework#2034). Still dev-only: several
344
+ // bundled screens that ARE self-navved (e.g. compliance-profiles'
345
+ // profile-picker) have real unmounted-client-plugin bugs in existing
346
+ // consumer apps today (kumiko-framework#2025) — running this in
347
+ // production before those are fixed (tracked via infra#503) would just
348
+ // spam every affected app's console.
349
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
350
+ const missingCustomScreens = app.features.flatMap((f) =>
351
+ f.screens
352
+ .filter(
353
+ (s) => s.type === "custom" && s.dormant !== true && customScreens[s.id] === undefined,
354
+ )
355
+ .map((s) => `${f.featureName}:${s.id}`),
356
+ );
357
+ if (missingCustomScreens.length > 0) {
358
+ // biome-ignore lint/suspicious/noConsole: dev-only diagnostic for missing client plugins
359
+ console.error(
360
+ `[kumiko] ${missingCustomScreens.length} custom screen(s) have no registered component in clientFeatures.components — they render an error placeholder instead of the intended UI: ${missingCustomScreens.join(", ")}. Add the feature's web client plugin to clientFeatures.`,
361
+ );
362
+ }
363
+ }
335
364
  // Column-renderer map, same last-wins semantics as customScreens —
336
365
  // duplicate keys across features are rarely intentional, so a
337
366
  // collision logs once instead of silently overriding a library's
@@ -353,8 +382,8 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
353
382
  app.features.flatMap((f) => f.contentCollections ?? []),
354
383
  );
355
384
 
356
- // Editor-Resolver aggregieren — keyed by "featureId:action". Gleiche
357
- // Last-Wins-Semantik wie columnRenderers. Warnung bei Kollision.
385
+ // Aggregate editor resolvers — keyed by "featureId:action". Same
386
+ // last-wins semantics as columnRenderers. Warns on collision.
358
387
  const resolvers = new Map<string, ResolverComponent>();
359
388
  for (const f of clientFeatures) {
360
389
  if (f.resolvers === undefined) continue;
@@ -425,10 +454,10 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
425
454
  return { root };
426
455
  }
427
456
 
428
- // TokensBoot nutzt den browser-backed TokensApi-Hook (class-based
429
- // dark-toggle) und reicht den Wert an den shared TokensProvider
430
- // durch. Keine eigene State-Haltungdie class auf <html> ist die
431
- // SSoT, useSyncExternalStore im Hook synced das in React.
457
+ // TokensBoot uses the browser-backed TokensApi hook (class-based
458
+ // dark-toggle) and passes the value through to the shared
459
+ // TokensProvider. No own statethe class on <html> is the
460
+ // SSoT, useSyncExternalStore in the hook syncs that into React.
432
461
  function TokensBoot({ children }: { readonly children: ReactNode }): ReactNode {
433
462
  const api = useBrowserTokensApi();
434
463
  return <TokensProvider value={api}>{children}</TokensProvider>;
@@ -471,9 +500,9 @@ function BrowserNavBoot({
471
500
  );
472
501
  }
473
502
 
474
- // Sucht das Feature, dem ein vollständig qualifizierter ScreenQn gehört.
475
- // Returns undefined wenn der Screen in keinem Feature-Schema deklariert
476
- // ist — KumikoScreen rendert dann den "Screen not found"-Banner.
503
+ // Finds the feature that owns a fully qualified ScreenQn.
504
+ // Returns undefined if the screen isn't declared in any feature schema —
505
+ // KumikoScreen then renders the "Screen not found" banner.
477
506
  function findOwnerFeature(app: AppSchema, qn: string): FeatureSchema | undefined {
478
507
  for (const feature of app.features) {
479
508
  for (const s of feature.screens) {
@@ -496,11 +525,11 @@ function RoutedScreen({
496
525
  }): ReactNode {
497
526
  const nav = useNav();
498
527
 
499
- // ScreenId aus dem Route ist NICHT qualified — nav.route.screenId
500
- // kommt aus dem URL-Path und ist die kurze Form ("order-list"). Wir
501
- // müssen das ans richtige Feature heften. Strategie: durch alle
502
- // Features iterieren bis das passende Screen-Decl auftaucht. Ohne
503
- // Match Fallback-Feature (das vom fallbackQn).
528
+ // ScreenId from the route is NOT qualified — nav.route.screenId
529
+ // comes from the URL path and is the short form ("order-list"). We
530
+ // need to pin it to the right feature. Strategy: iterate through all
531
+ // features until the matching screen decl turns up. No match →
532
+ // fallback feature (the one from fallbackQn).
504
533
  const { feature, qn, entityId } = useMemo(() => {
505
534
  if (nav.route === undefined) {
506
535
  return {
@@ -532,17 +561,17 @@ function RoutedScreen({
532
561
  >(() => {
533
562
  if (onRowClick !== undefined) return onRowClick;
534
563
  return (row, entityName) => {
535
- // Edit-Screen für die Entity über alle Features suchen im
536
- // Single-Feature-Setup ist das das gleiche Feature wie das aktive,
537
- // im Multi-Feature kann der Edit theoretisch in einem anderen
538
- // Feature liegen (eines, das die Entity teilt).
564
+ // Search for the edit screen for the entity across all features
565
+ // in a single-feature setup that's the same feature as the active
566
+ // one, in multi-feature the edit could theoretically live in a
567
+ // different feature (one that shares the entity).
539
568
  for (const f of app.features) {
540
569
  const editScreen = f.screens.find(
541
570
  (s) => s.type === "entityEdit" && s.entity === entityName,
542
571
  );
543
572
  if (editScreen) {
544
- // editScreen.id ist QN-Form (registry-stamped); nav.navigate
545
- // erwartet Short-Form. Sonst wird die URL doppelt-qualifiziert.
573
+ // editScreen.id is QN form (registry-stamped); nav.navigate
574
+ // expects the short form. Otherwise the URL gets double-qualified.
546
575
  nav.navigate({ screenId: lastSegment(editScreen.id), entityId: row.id });
547
576
  return;
548
577
  }
@@ -550,12 +579,12 @@ function RoutedScreen({
550
579
  };
551
580
  }, [onRowClick, app.features, nav]);
552
581
 
553
- // Copy-Link-Action (Issue #912) für entityEdit-Update-Screens. Baut die
554
- // absolute Permalink-URL aus der aktuellen Route + kopiert sie
555
- // `navigator`/`window` sind hier erlaubt (renderer-web, kein
556
- // platform-neutrales Package). Kein Button ohne entityId (create-mode).
557
- // Silent-catch bei Clipboard-Fehler (non-secure context) mirrort das
558
- // bestehende Muster in pat-tokens-screen.tsx.
582
+ // Copy-link action (Issue #912) for entityEdit update screens. Builds
583
+ // the absolute permalink URL from the current route + copies it
584
+ // `navigator`/`window` are allowed here (renderer-web, not a
585
+ // platform-neutral package). No button without entityId (create-mode).
586
+ // Silent-catch on clipboard error (non-secure context) mirrors the
587
+ // existing pattern in pat-tokens-screen.tsx.
559
588
  const effectiveOnCopyLink = useMemo<(() => Promise<void> | void) | undefined>(() => {
560
589
  const route = nav.route;
561
590
  if (route?.entityId === undefined) return undefined;
package/src/index.ts CHANGED
@@ -71,6 +71,8 @@ export {
71
71
  RenderEdit,
72
72
  RenderField,
73
73
  RenderList,
74
+ synthesizeActionFormEntity,
75
+ synthesizeActionFormScreen,
74
76
  TokensProvider,
75
77
  UserRolesProvider,
76
78
  useDispatcher,
@@ -17,7 +17,7 @@ import type {
17
17
  TreeAction,
18
18
  TreeNode,
19
19
  } from "@cosmicdrift/kumiko-framework/engine";
20
- import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
20
+ import type { NavDefinition, NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
21
21
  import type { NavNode, NavRegistrySlice } from "@cosmicdrift/kumiko-headless";
22
22
  import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
23
23
  import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
@@ -48,6 +48,7 @@ import {
48
48
  Hash,
49
49
  Home,
50
50
  KeyRound,
51
+ Languages,
51
52
  Layers,
52
53
  LayoutDashboard,
53
54
  LayoutGrid,
@@ -106,12 +107,18 @@ import {
106
107
  import { useDispatchTarget } from "./target-resolver-stub";
107
108
  import { parseTargetFromSearchParams } from "./target-url";
108
109
 
109
- // Nav-Icon-Registry: ein Nav-Eintrag setzt `icon: "<key>"` (im r.nav-Decl),
110
- // der Renderer mappt den symbolischen Key auf ein lucide-Component. Unknown
111
- // Keys kein Icon (sauberer Fallback auf den Dot, kein Boot-Fail). Spiegelt
112
- // das NODE_ICONS-Muster vom Visual-Tree. App-Authors referenzieren nur diese
113
- // Keys; Erweiterung = neuer Eintrag hier (eine Quelle, alle Apps).
114
- const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
110
+ // Nav-icon registry: a nav entry sets `icon: "<key>"` (in the r.nav decl),
111
+ // the renderer maps the symbolic key to a lucide component. `NavIconKey` is
112
+ // the closed vocabulary a feature author can write (packages/types/src/
113
+ // nav-icon.ts); `satisfies` below makes this map a compile-time drift
114
+ // guard a key added to one without the other fails the build.
115
+ //
116
+ // `node.icon`/`TreeAction.icon` stay plain `string` at the resolved-tree
117
+ // layer (dynamic/provider-supplied data isn't statically known), so the
118
+ // runtime `Object.hasOwn` lookups below still see an unknown key on
119
+ // occasion — that's the defense-in-depth fallback to the dot, not the
120
+ // primary guard anymore.
121
+ const NAV_ICONS = {
115
122
  dashboard: LayoutDashboard,
116
123
  "layout-grid": LayoutGrid,
117
124
  "book-open": BookOpen,
@@ -158,7 +165,13 @@ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
158
165
  rocket: Rocket,
159
166
  // Was imported but never registered — `icon: "plus"` silently fell back.
160
167
  plus: Plus,
161
- };
168
+ languages: Languages,
169
+ } as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
170
+
171
+ // Widened alias for the two lookup sites below, which index by the plain
172
+ // `string` icon key of the resolved NavNode/TreeAction tree — not the
173
+ // closed NavIconKey union NAV_ICONS itself is typed against.
174
+ const NAV_ICON_LOOKUP: Readonly<Record<string, typeof Folder | undefined>> = NAV_ICONS;
162
175
 
163
176
  export type NavTreeProps = {
164
177
  // Akzeptiert beide Shapes — AppSchema (multi-feature) oder
@@ -336,8 +349,17 @@ function NavLeadingIcon({
336
349
  label?: string;
337
350
  }): ReactNode {
338
351
  const iconKey = expanded && node.icon === "folder" ? "folder-open" : node.icon;
339
- const NavIcon =
340
- iconKey !== undefined && Object.hasOwn(NAV_ICONS, iconKey) ? NAV_ICONS[iconKey] : undefined;
352
+ const isKnownIcon = iconKey !== undefined && Object.hasOwn(NAV_ICONS, iconKey);
353
+ const NavIcon = iconKey !== undefined && isKnownIcon ? NAV_ICON_LOOKUP[iconKey] : undefined;
354
+ useEffect(() => {
355
+ if (iconKey !== undefined && !isKnownIcon) {
356
+ // biome-ignore lint/suspicious/noConsole: diagnostic for unregistered nav icon keys
357
+ console.warn(
358
+ `[kumiko] Nav entry "${node.qualifiedName}" references icon "${iconKey}", which is not ` +
359
+ `registered in NAV_ICONS — falling back to the dot indicator. Check the icon key spelling.`,
360
+ );
361
+ }
362
+ }, [iconKey, isKnownIcon, node.qualifiedName]);
341
363
  if (NavIcon !== undefined) return <NavIcon aria-hidden="true" className="shrink-0" />;
342
364
  const initial = label?.trim().charAt(0).toUpperCase();
343
365
  return (
@@ -526,10 +548,10 @@ function useNavNodeState(node: NavNode, collapsed: ReadonlySet<string>): NavNode
526
548
  };
527
549
  }
528
550
 
529
- // Action-Icon-Lookup: bekannter NAV_ICONS-Key → Lucide, sonst der rohe
530
- // String als Text (Provider-Konvention, kein Boot-Fail bei unknown).
551
+ // Action icon lookup: registered NAV_ICONS key → Lucide icon, otherwise the
552
+ // raw string as text (provider convention, no boot-fail on an unknown key).
531
553
  function ActionGlyph({ icon }: { readonly icon: string }): ReactNode {
532
- const Icon = Object.hasOwn(NAV_ICONS, icon) ? NAV_ICONS[icon] : undefined;
554
+ const Icon = Object.hasOwn(NAV_ICONS, icon) ? NAV_ICON_LOOKUP[icon] : undefined;
533
555
  if (Icon !== undefined) return <Icon aria-hidden className="size-3.5" />;
534
556
  return (
535
557
  <span aria-hidden className="text-xs">
@@ -171,4 +171,16 @@ describe("formatDatePlaceholder", () => {
171
171
  test("en-GB → day/month/year pattern with slash separator", () => {
172
172
  expect(formatDatePlaceholder("en-GB", enPlaceholderLetters)).toBe("DD/MM/YYYY");
173
173
  });
174
+
175
+ // #1879: a missing i18n key falls back to the raw, multi-character key
176
+ // string (see i18n.tsx) — the placeholder must clamp to one char per slot
177
+ // instead of repeating the whole fallback key.
178
+ test("multi-character letters (e.g. raw i18n fallback key) clamp to first code point", () => {
179
+ const rawKeyLetters = {
180
+ year: "kumiko.field.dateField.placeholderYear",
181
+ month: "kumiko.field.dateField.placeholderMonth",
182
+ day: "kumiko.field.dateField.placeholderDay",
183
+ };
184
+ expect(formatDatePlaceholder("de-DE", rawKeyLetters)).toBe("kk.kk.kkkk");
185
+ });
174
186
  });
@@ -281,6 +281,32 @@ describe("EmbeddedListInput — issue rendering", () => {
281
281
  ).toBeTruthy();
282
282
  });
283
283
 
284
+ // #1876: a long validation message in a narrow cell must wrap instead of
285
+ // widening the whole `min-w-max` desktop table into horizontal scroll.
286
+ test("cellIssues in the desktop table are width-constrained and wrap", () => {
287
+ const rows = [{ description: "A", quantity: 1, amount: 100 }];
288
+ renderWithLocale(
289
+ <EmbeddedListInput
290
+ {...baseProps({
291
+ rows,
292
+ cellIssues: {
293
+ "0.description": [
294
+ issue(
295
+ "lines.0.description",
296
+ "This is a deliberately long validation message that must wrap instead of widening the table",
297
+ ),
298
+ ],
299
+ },
300
+ })}
301
+ />,
302
+ );
303
+ const desktop = within(screen.getByTestId("lines-desktop"));
304
+ const container = desktop.getByTestId("lines-cell-0-description-errors");
305
+ expect(container.className).toContain("max-w-[16rem]");
306
+ expect(container.className).toContain("whitespace-normal");
307
+ expect(container.className).toContain("break-words");
308
+ });
309
+
284
310
  test("rowIssues render under the matching row", () => {
285
311
  const rows = [{ description: "A", quantity: 1, amount: 100 }];
286
312
  renderWithLocale(
@@ -105,15 +105,22 @@ function localeDateOrder(locale: string): readonly DateSlot[] {
105
105
  // from formatToParts — nothing hardcoded per locale. `letters` is one
106
106
  // character per slot (from i18n); repeated to the slot's digit count
107
107
  // (day/month 2, year 4).
108
+ // Clamps each slot letter to its first code point — `letters` normally comes
109
+ // from i18n and can fall back to the raw, multi-character translation key
110
+ // when a lookup misses, which would otherwise blow up the placeholder length.
111
+ function firstCodePoint(s: string): string {
112
+ return [...s][0] ?? "?";
113
+ }
114
+
108
115
  export function formatDatePlaceholder(
109
116
  locale: string,
110
117
  letters: { readonly year: string; readonly month: string; readonly day: string },
111
118
  ): string {
112
119
  return localeDateParts(locale)
113
120
  .map((part) => {
114
- if (part.type === "year") return letters.year.repeat(4);
115
- if (part.type === "month") return letters.month.repeat(2);
116
- if (part.type === "day") return letters.day.repeat(2);
121
+ if (part.type === "year") return firstCodePoint(letters.year).repeat(4);
122
+ if (part.type === "month") return firstCodePoint(letters.month).repeat(2);
123
+ if (part.type === "day") return firstCodePoint(letters.day).repeat(2);
117
124
  return part.value;
118
125
  })
119
126
  .join("");
@@ -121,14 +121,27 @@ function parsePasteGrid(text: string): readonly (readonly string[])[] {
121
121
  function IssueMessages({
122
122
  issues,
123
123
  testId,
124
+ constrainWidth,
124
125
  }: {
125
126
  readonly issues: readonly FieldIssue[] | undefined;
126
127
  readonly testId?: string;
128
+ // Table cells sit inside a `min-w-max` table (see EmbeddedListInput below):
129
+ // a long, unbounded message there widens the whole table instead of
130
+ // wrapping, forcing horizontal scroll. Only the desktop table-cell path
131
+ // needs this — mobile cards and full-width row/list issues wrap naturally.
132
+ readonly constrainWidth?: boolean;
127
133
  }): ReactNode {
128
134
  const t = useTranslation();
129
135
  if (issues === undefined || issues.length === 0) return null;
130
136
  return (
131
- <div role="alert" data-testid={testId} className="text-xs text-destructive">
137
+ <div
138
+ role="alert"
139
+ data-testid={testId}
140
+ className={cn(
141
+ "text-xs text-destructive",
142
+ constrainWidth === true && "max-w-[16rem] whitespace-normal break-words",
143
+ )}
144
+ >
132
145
  {issues.map((issue) => (
133
146
  <div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey, issue.params)}</div>
134
147
  ))}
@@ -554,6 +567,7 @@ export function EmbeddedListInput({
554
567
  <IssueMessages
555
568
  issues={issues}
556
569
  testId={testIdFor(`cell-${rowIndex}-${column.field}-errors`)}
570
+ constrainWidth
557
571
  />
558
572
  </TableCell>
559
573
  );
@@ -0,0 +1,99 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import type { LiveEvent } from "@cosmicdrift/kumiko-renderer";
3
+ import { createEventSourceLiveEvents } from "../live-events";
4
+
5
+ // happy-dom doesn't provide EventSource, and this module only needs
6
+ // `typeof window !== "undefined"` to unlock — no real DOM required. Stub
7
+ // both globals directly instead of pulling in the project's DOM test config.
8
+ class FakeEventSource {
9
+ static instances: FakeEventSource[] = [];
10
+ private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
11
+
12
+ constructor(readonly url: string) {
13
+ FakeEventSource.instances.push(this);
14
+ }
15
+
16
+ addEventListener(type: string, listener: (e: MessageEvent) => void): void {
17
+ let set = this.listeners.get(type);
18
+ if (!set) {
19
+ set = new Set();
20
+ this.listeners.set(type, set);
21
+ }
22
+ set.add(listener);
23
+ }
24
+
25
+ close(): void {}
26
+
27
+ dispatch(entityName: string, data: unknown): void {
28
+ const event = { data: JSON.stringify(data) } as MessageEvent;
29
+ for (const listener of this.listeners.get(entityName) ?? []) listener(event);
30
+ }
31
+ }
32
+
33
+ const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
34
+ const originalEventSource = Object.getOwnPropertyDescriptor(globalThis, "EventSource");
35
+
36
+ beforeEach(() => {
37
+ FakeEventSource.instances.length = 0;
38
+ // biome-ignore lint/suspicious/noExplicitAny: test-only global stub
39
+ (globalThis as any).window = globalThis;
40
+ // biome-ignore lint/suspicious/noExplicitAny: test-only global stub
41
+ (globalThis as any).EventSource = FakeEventSource;
42
+ });
43
+
44
+ afterEach(() => {
45
+ if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow);
46
+ else delete (globalThis as { window?: unknown }).window;
47
+ if (originalEventSource) Object.defineProperty(globalThis, "EventSource", originalEventSource);
48
+ else delete (globalThis as { EventSource?: unknown }).EventSource;
49
+ });
50
+
51
+ function entityEvent(overrides: Partial<LiveEvent["data"]> = {}): LiveEvent["data"] {
52
+ return {
53
+ id: "e1",
54
+ aggregateType: "invoice",
55
+ version: 1,
56
+ payload: {},
57
+ createdAt: "2026-01-01T00:00:00.000Z",
58
+ ...overrides,
59
+ };
60
+ }
61
+
62
+ describe("createEventSourceLiveEvents", () => {
63
+ test("any verb — including business verbs and the auto-verb 'forgotten' — triggers the entity listener", () => {
64
+ const liveEvents = createEventSourceLiveEvents();
65
+ const received: LiveEvent[] = [];
66
+ const unsubscribe = liveEvents("invoice", (event) => received.push(event));
67
+
68
+ const source = FakeEventSource.instances.at(-1);
69
+ expect(source).toBeDefined();
70
+ // The server now names every frame after the entity, not the verb — a
71
+ // business verb like "archived" or the auto-verb "forgotten" never
72
+ // needed its own listener because no verb-specific listener exists.
73
+ source?.dispatch("invoice", entityEvent({ id: "archived-1" }));
74
+ source?.dispatch("invoice", entityEvent({ id: "forgotten-1" }));
75
+
76
+ expect(received).toHaveLength(2);
77
+ expect(received[0]?.type).toBe("invoice");
78
+ expect(received[1]?.data.id).toBe("forgotten-1");
79
+
80
+ unsubscribe();
81
+ });
82
+
83
+ test("a subscriber for one entity does not receive another entity's frame", () => {
84
+ const liveEvents = createEventSourceLiveEvents();
85
+ const invoiceEvents: LiveEvent[] = [];
86
+ const userEvents: LiveEvent[] = [];
87
+ const unsubInvoice = liveEvents("invoice", (event) => invoiceEvents.push(event));
88
+ const unsubUser = liveEvents("user", (event) => userEvents.push(event));
89
+
90
+ const source = FakeEventSource.instances.at(-1);
91
+ source?.dispatch("invoice", entityEvent({ aggregateType: "invoice" }));
92
+
93
+ expect(invoiceEvents).toHaveLength(1);
94
+ expect(userEvents).toHaveLength(0);
95
+
96
+ unsubInvoice();
97
+ unsubUser();
98
+ });
99
+ });
@@ -3,15 +3,13 @@ import type { LiveEvent, LiveEventSubscriber } from "@cosmicdrift/kumiko-rendere
3
3
  // EventSource-backed Live-Events für den Web-Renderer. Der shared
4
4
  // Layer konsumiert nur das `LiveEventSubscriber`-Interface; diese Datei
5
5
  // liefert eine Factory die intern eine EventSource auf /api/sse aufbaut,
6
- // pro (entity, verb)-Kombi einen addEventListener verdrahtet und
7
- // subscriptions routet.
6
+ // pro Entity EINEN addEventListener verdrahtet (Server benennt den Frame
7
+ // nach dem aggregateType, siehe sse-route.ts) und subscriptions routet.
8
8
  //
9
9
  // Verbindungs-Lifecycle: lazy beim ersten subscribe, close wenn der
10
10
  // letzte unsubscribe feuert. Mehrere Consumer teilen sich dieselbe
11
11
  // EventSource, sparen CPU + Server-Load.
12
12
 
13
- const VERBS = ["created", "updated", "deleted", "restored"] as const;
14
-
15
13
  type EntitySubscriber = {
16
14
  readonly entityName: string;
17
15
  readonly listener: (event: LiveEvent) => void;
@@ -34,18 +32,21 @@ export function createEventSourceLiveEvents(
34
32
 
35
33
  const subscribers = new Set<EntitySubscriber>();
36
34
  let source: EventSource | undefined;
37
- const wiredTypes = new Set<string>();
35
+ const wiredEntities = new Set<string>();
38
36
 
39
- const handleEvent = (type: string, raw: string): void => {
37
+ // `subscribers` is a flat Set across all entities — the browser-side
38
+ // addEventListener(entityName, ...) gate below only filters which frames
39
+ // arrive at all, not which subscriber a given frame is for. Without this
40
+ // filter, an `invoice` subscriber would also fire on every `user` frame.
41
+ const handleEvent = (raw: string): void => {
40
42
  let parsed: LiveEvent["data"];
41
43
  try {
42
44
  parsed = JSON.parse(raw) as LiveEvent["data"];
43
45
  } catch {
44
46
  // skip: malformed SSE payload, drop it rather than crash all subscribers
45
- // zu crashen und alle anderen subscribers mitzureißen.
46
47
  return;
47
48
  }
48
- const event: LiveEvent = { type, data: parsed };
49
+ const event: LiveEvent = { type: parsed.aggregateType, data: parsed };
49
50
  for (const sub of subscribers) {
50
51
  if (sub.entityName === parsed.aggregateType) sub.listener(event);
51
52
  }
@@ -62,14 +63,12 @@ export function createEventSourceLiveEvents(
62
63
  const ensureListenersForEntity = (entityName: string): void => {
63
64
  // skip: not connected yet, listeners get wired once ensureConnected runs
64
65
  if (source === undefined) return;
65
- for (const verb of VERBS) {
66
- const type = `${entityName}.${verb}`;
67
- if (wiredTypes.has(type)) continue;
68
- source.addEventListener(type, (e) => {
69
- handleEvent(type, (e as MessageEvent).data);
70
- });
71
- wiredTypes.add(type);
72
- }
66
+ // skip: already wired for this entity
67
+ if (wiredEntities.has(entityName)) return;
68
+ source.addEventListener(entityName, (e) => {
69
+ handleEvent((e as MessageEvent).data);
70
+ });
71
+ wiredEntities.add(entityName);
73
72
  };
74
73
 
75
74
  const closeIfEmpty = (): void => {
@@ -79,7 +78,7 @@ export function createEventSourceLiveEvents(
79
78
  if (source === undefined) return;
80
79
  source.close();
81
80
  source = undefined;
82
- wiredTypes.clear();
81
+ wiredEntities.clear();
83
82
  };
84
83
 
85
84
  return (entityName, listener) => {