Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .serena/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,17 @@ encoding: utf-8
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
- typescript

# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:

# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
File renamed without changes.
177 changes: 177 additions & 0 deletions docs/DUPLICATE_EACH_BLOCK_KEY_AUDIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Svelte `{#each}` Block Duplicate Key Audit

## Background

In Svelte, `{#each items as item (key)}` blocks use the key expression to track item identity across DOM updates. When keys are duplicated, Svelte cannot correctly associate DOM nodes with data items, leading to rendering bugs—stale state, mismatched content, or unnecessary DOM teardown and recreation.

This audit reviewed every keyed `{#each}` block in the codebase and fixed those at risk of duplicate or ineffective keys.

---

## High Risk Fixes

### 1. `workflow-callback.svelte:48` — `||` changed to `??`

**Before:**

```svelte
{#each links as link, i (link.workflowEvent?.eventRef?.eventId || link.workflowEvent?.requestIdRef?.requestId || i)}
```

**After:**

```svelte
{#each links as link, i (link.workflowEvent?.eventRef?.eventId ?? link.workflowEvent?.requestIdRef?.requestId ?? i)}
```

**Problem:** The `||` operator treats all falsy values (`0`, `""`, `false`) as missing and skips to the next fallback. If an `eventId` were `0` (a valid ID), it would be silently ignored and the key would fall through to `requestIdRef` or the index.

**Fix:** `??` (nullish coalescing) only falls back on `null` or `undefined`, preserving valid falsy IDs like `0`.

---

### 2. `combobox.svelte:480` — Added missing key

**Before:**

```svelte
{#each value.slice(0, chipLimit) as v}
```

**After:**

```svelte
{#each value.slice(0, chipLimit) as v (v)}
```

**Problem:** No key expression at all. When a chip is removed from the middle of the selected values, Svelte falls back to index-based diffing—potentially misassociating chip components with the wrong values, causing stale `onremove` handlers or visual glitches.

**Fix:** Added `(v)` as the key. Multiselect combobox values are unique strings (you can't select the same option twice), so the value itself is a stable, unique identifier.

---

### 3. `schedule-recent-runs.svelte:48` — Fixed fallback key collision

**Before:**

```svelte
{#each sortRecentRuns(recentRuns) as run, i (`${run?.startWorkflowResult?.workflowId ?? i}:${run?.startWorkflowResult?.runId ?? i + 1}`)}
```

**After:**

```svelte
{#each sortRecentRuns(recentRuns) as run, i (`${run?.startWorkflowResult?.workflowId ?? `_${i}`}:${run?.startWorkflowResult?.runId ?? `_${i}`}`)}
```

**Problem:** When `workflowId` or `runId` is null, the key falls back to the raw index number. A run at index 1 with null fields would produce key `"1:2"`. A real run with `workflowId="1"` and `runId="2"` would produce the same key `"1:2"` — a collision.

**Fix:** Prefix fallback values with `_` (e.g., `"_1:_1"`), which can never collide with real workflow/run IDs since those don't start with underscores.

---

## Medium Risk Fixes

### 4–6. Filter lists — Added stable `id` to `SearchAttributeFilter`

**Files changed:**

- `search-attribute-filter/filter-list.svelte`
- `workflow/filter-bar/dropdown-filter-list.svelte`
- `activities-summary-filter-bar/dropdown-filter-list.svelte`

**Before:**

```svelte
{#each visibleFilters as workflowFilter, i (`${workflowFilter.attribute}-${i}`)}
```

**After:**

```svelte
{#each visibleFilters as workflowFilter, i (workflowFilter.id)}
```

**Problem:** Filters allow the same attribute multiple times (e.g., two `StartTime` filters for a date range), so `attribute` alone isn't unique. The index suffix made the composite key technically unique but defeated the purpose of keying — removing a filter from the middle caused every subsequent filter chip to get a new key, triggering full DOM teardown and recreation instead of a simple removal.

**Fix:** Added an `id: string` field to the `SearchAttributeFilter` type and a `generateFilterId()` factory function in `search-attribute-filters.ts`. Every filter now gets a stable, unique ID at creation time that persists across list mutations.

**Supporting changes across all filter construction sites:**

- `to-list-workflow-filters.ts` — `emptyFilter()` now includes `id`
- `workflow-datetime-filter.svelte` — 2 construction sites
- `text-filter.svelte` — 1 construction site
- `workflow-status.svelte` — `mapStatusToFilter()`
- `status-dropdown-filter-chip.svelte` — 2 construction sites
- `filterable-table-cell.svelte` (workflow) — 1 construction site
- `filterable-table-cell.svelte` (activities) — 1 construction site
- `activity-counts.svelte` — 1 construction site
- `workflow-counts.svelte` — 1 construction site

**Test changes:**

- `to-list-workflow-filters.test.ts` — Changed `toEqual` to `toMatchObject` for filter assertions since test expectations don't need to assert on the `id` field.

---

### 7. `orderable-list.svelte:36` — Dropped redundant index

**Before:**

```svelte
{#each columnsInUse as { label }, index (`${label}:${index}`)}
```

**After:**

```svelte
{#each columnsInUse as { label }, index (label)}
```

**Problem:** Column labels are unique within a table configuration (the UI prevents adding the same column twice). The `:${index}` suffix added no uniqueness value but prevented Svelte from recognizing that a column at a new position was the same column — causing unnecessary DOM recreation when columns were reordered.

**Fix:** Use `label` alone as the key, enabling Svelte to properly track columns across reorders.

---

### 8. `event-summary-table.svelte:94` — Dropped redundant index

**Before:**

```svelte
{#each columns as column, i (`${column.label}:${i}`)}
```

**After:**

```svelte
{#each columns as column (column.label)}
```

**Problem:** Same pattern as orderable-list — table header column labels are unique by definition (each column has a distinct label). The index suffix was unnecessary noise.

**Fix:** Use `column.label` alone.

---

## Items Reviewed and Left As-Is

### `add-search-attributes.svelte:45` — `${attribute.label}-${id}`

Index IS the identity here by design. The component uses `bind:attribute={attributesToAdd[id]}` where `id` is the loop index, meaning the index is the binding target. Changing the key wouldn't improve behavior.

### `chip-input.svelte:114,175` — `${chip}-${i}`

Chips are user-entered strings that can legitimately be duplicated (e.g., entering "foo" twice). Using `chip` alone would break on duplicates. The `${chip}-${i}` composite is an acceptable tradeoff — the DOM churn on removal is negligible for a handful of simple chip components.

### `calendar.svelte:38` — `(index)`

Pure index key is equivalent to no key, but calendar cells never reorder or mutate, so there's no practical impact.

### All `(label)` keys on table rows

`deployment-table-row.svelte`, `version-table-row.svelte`, `schedules-table-row.svelte`, `workflow-actions.svelte` — these use translated label strings as keys. Column definitions are static per table, so labels are always unique. Low risk.

### All ID-based keys

Keys using `activity.id`, `command.id`, `endpoint.id`, `workflow.id:runId` composites, enum-based status keys, and `iterableKey(event)` are all genuinely unique and correct.
2 changes: 1 addition & 1 deletion src/lib/components/event/event-summary-table.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
class="border-t-0"
>
<TableHeaderRow slot="headers" class="!h-8">
{#each columns as column, i (`${column.label}:${i}`)}
{#each columns as column (column.label)}
<TableHeaderCell {column}>
{#if column.label === 'Event Type'}
<EventHistoryLegend eventTypesOnly />
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/schedule/schedule-recent-runs.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
{translate('common.view-all-runs')}
</Link>
</div>
{#each sortRecentRuns(recentRuns) as run, i (`${run?.startWorkflowResult?.workflowId ?? i}:${run?.startWorkflowResult?.runId ?? i + 1}`)}
{#each sortRecentRuns(recentRuns) as run, i (`${run?.startWorkflowResult?.workflowId ?? `_${i}`}:${run?.startWorkflowResult?.runId ?? `_${i}`}`)}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • ⚠️ 'run.startWorkflowResult' is possibly 'null' or 'undefined'.
  • ⚠️ 'run.startWorkflowResult' is possibly 'null' or 'undefined'.
  • ⚠️ Argument of type 'string | null | undefined' is not assignable to parameter of type 'string'.
  • ⚠️ Type 'string | null | undefined' is not assignable to type 'string | undefined'.

{#await fetchWorkflowForSchedule({ namespace, workflowId: decodeURIForSvelte(run.startWorkflowResult.workflowId), runId: run.startWorkflowResult.runId }, fetch) then workflow}
<div class="row">
<div class="w-28">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
</script>

<div class="flex flex-wrap gap-2" class:pt-2={visibleFilters.length}>
{#each visibleFilters as workflowFilter, i (`${workflowFilter.attribute}-${i}`)}
{#each visibleFilters as workflowFilter, i (workflowFilter.id)}
{@const { attribute, value, conditional, customDate } = workflowFilter}
{#if attribute}
<div in:fade data-testid="{workflowFilter.attribute}-{i}">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
import FilterOrCopyButtons from '$lib/holocene/filter-or-copy-buttons.svelte';
import Link from '$lib/holocene/link.svelte';
import { translate } from '$lib/i18n/translate';
import type { SearchAttributeFilter } from '$lib/models/search-attribute-filters';
import {
generateFilterId,
type SearchAttributeFilter,
} from '$lib/models/search-attribute-filters';
import { activityFilters } from '$lib/stores/filters';
import {
SEARCH_ATTRIBUTE_TYPE,
Expand Down Expand Up @@ -34,6 +37,7 @@

if (!filter || filter.value !== value) {
const newFilter: SearchAttributeFilter = {
id: generateFilterId(),
attribute,
type,
value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@

{#if visibleFilters.length > 0}
<div class="flex flex-wrap items-center gap-2">
{#each visibleFilters as activityFilter, i (activityFilter.attribute + '-' + i)}
{#each visibleFilters as activityFilter, i (activityFilter.id)}
{#if isStatusFilter(activityFilter) && i === firstExecutionStatusIndex}
<StatusDropdownFilterChip
filters={executionStatusFilters}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { page } from '$app/state';

import Skeleton from '$lib/holocene/skeleton/index.svelte';
import { generateFilterId } from '$lib/models/search-attribute-filters';
import { fetchActivityCountByStatus } from '$lib/services/activity-counts';
import { activityCount, activityRefresh } from '$lib/stores/activities';
import { activityFilters } from '$lib/stores/filters';
Expand Down Expand Up @@ -49,6 +50,7 @@

if (!statusExists) {
const filter = {
id: generateFilterId(),
attribute: 'ExecutionStatus',
type: SEARCH_ATTRIBUTE_TYPE.KEYWORD,
value: status,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<svelte:fragment slot="heading">
{type} <span class="font-normal">(in view)</span>
</svelte:fragment>
{#each columnsInUse as { label }, index (`${label}:${index}`)}
{#each columnsInUse as { label }, index (label)}
<OrderableListItem
{index}
{label}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
import Input from '$lib/holocene/input/input.svelte';
import { Menu, MenuButton, MenuContainer } from '$lib/holocene/menu';
import { translate } from '$lib/i18n/translate';
import type { SearchAttributeFilter } from '$lib/models/search-attribute-filters';
import {
generateFilterId,
type SearchAttributeFilter,
} from '$lib/models/search-attribute-filters';
import {
attributeToHumanReadable,
attributeToId,
Expand All @@ -27,6 +30,7 @@
const { value } = e.target as HTMLInputElement;
if (value) {
const filter: SearchAttributeFilter = {
id: generateFilterId(),
attribute,
type: SEARCH_ATTRIBUTE_TYPE.KEYWORD,
value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
import MenuDivider from '$lib/holocene/menu/menu-divider.svelte';
import TimePicker from '$lib/holocene/time-picker.svelte';
import { translate } from '$lib/i18n/translate';
import type { SearchAttributeFilter } from '$lib/models/search-attribute-filters';
import {
generateFilterId,
type SearchAttributeFilter,
} from '$lib/models/search-attribute-filters';
import { supportsAdvancedVisibility } from '$lib/stores/advanced-visibility';
import { workflowFilters } from '$lib/stores/filters';
import { SEARCH_ATTRIBUTE_TYPE } from '$lib/types/workflows';
Expand Down Expand Up @@ -82,6 +85,7 @@
custom = true;
} else {
const filter: SearchAttributeFilter = {
id: generateFilterId(),
attribute: timeField,
type: SEARCH_ATTRIBUTE_TYPE.DATETIME,
value,
Expand Down Expand Up @@ -156,6 +160,7 @@
: `> "${formatISO(startDateWithTime)}"`;

const filter: SearchAttributeFilter = {
id: generateFilterId(),
attribute: timeField,
type: SEARCH_ATTRIBUTE_TYPE.DATETIME,
value: query,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
} from '$lib/holocene/menu';
import { translate } from '$lib/i18n/translate';
import Translate from '$lib/i18n/translate.svelte';
import type { SearchAttributeFilter } from '$lib/models/search-attribute-filters';
import {
generateFilterId,
type SearchAttributeFilter,
} from '$lib/models/search-attribute-filters';
import { workflowStatusFilters } from '$lib/models/workflow-status';
import { workflowFilters } from '$lib/stores/filters';
import { SEARCH_ATTRIBUTE_TYPE } from '$lib/types/workflows';
Expand All @@ -24,6 +27,7 @@

function mapStatusToFilter(value: string): SearchAttributeFilter {
return {
id: generateFilterId(),
attribute: 'ExecutionStatus',
type: SEARCH_ATTRIBUTE_TYPE.KEYWORD,
value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@

{#if visibleFilters.length > 0}
<div class="flex flex-wrap items-center gap-2">
{#each visibleFilters as workflowFilter, i (workflowFilter.attribute + '-' + i)}
{#each visibleFilters as workflowFilter, i (workflowFilter.id)}
{#if isStatusFilter(workflowFilter) && i === firstExecutionStatusIndex}
<StatusDropdownFilterChip
filters={executionStatusFilters}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
import { Menu, MenuButton, MenuContainer } from '$lib/holocene/menu';
import MenuItem from '$lib/holocene/menu/menu-item.svelte';
import Translate from '$lib/i18n/translate.svelte';
import type { SearchAttributeFilter } from '$lib/models/search-attribute-filters';
import {
generateFilterId,
type SearchAttributeFilter,
} from '$lib/models/search-attribute-filters';
import { workflowStatusFilters } from '$lib/models/workflow-status';

type Props = {
Expand Down Expand Up @@ -39,6 +42,7 @@
if (localFilters.length === 1 && localFilters[0].value === '') {
localFilters = [
{
id: generateFilterId(),
attribute: 'ExecutionStatus',
operator: '',
parenthesis: '',
Expand All @@ -51,6 +55,7 @@
localFilters = [
...localFilters,
{
id: generateFilterId(),
attribute: 'ExecutionStatus',
operator: '',
parenthesis: localFilters.length ? ')' : '',
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/workflow/workflow-callback.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
<Alert icon="nexus" intent={failed ? 'error' : 'info'} {title}>
<div class="flex flex-col gap-2 pt-2">
{#if links.length}
{#each links as link, i (link.workflowEvent?.eventRef?.eventId || link.workflowEvent?.requestIdRef?.requestId || i)}
{#each links as link, i (link.workflowEvent?.eventRef?.eventId ?? link.workflowEvent?.requestIdRef?.requestId ?? i)}
<EventLink {link} />
<EventLink
{link}
Expand Down
Loading
Loading