Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ pnpm test -- tests/path/to/test.ts

## Testing

Tests live in `tests/` and mirror the `src/` directory structure. Test files end in `.test.ts` or `.test.tsx`.
Tests live in `tests/` and mirror the `src/` directory structure. Test files end in `.test.ts` or `.test.tsx`. BDD step definitions end in `.steps.tsx` and are also picked up by Vitest automatically.

Factory helpers in `tests/helpers/index.tsx` (`makeIssue`, `makePullRequest`, `makeWorkflowRun`) give you typed test fixtures — use them instead of hand-rolling objects.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"zod": "4.3.6"
},
"devDependencies": {
"@amiceli/vitest-cucumber": "6.3.0",
"@cloudflare/vite-plugin": "1.30.1",
"@cloudflare/vitest-pool-workers": "0.13.4",
"@playwright/test": "1.58.2",
Expand Down
82 changes: 82 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 39 additions & 7 deletions src/app/components/onboarding/RepoSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,21 +342,53 @@ export default function RepoSelector(props: RepoSelectorProps) {
new Set((props.monitoredRepos ?? []).map((r) => r.fullName))
);

// Plain let — not signals; mutating a signal inside createMemo causes infinite re-evaluation.
let frozenOrder: string[] | null = null;
let frozenOrgsKey = "";

const sortedOrgStates = createMemo(() => {
const states = orgStates();
// Defer sorting until all orgs have loaded: prevents layout shift during
// trickle-in, and ensures each org's type ("user" vs "org") is resolved
// from fetchOrgs before we sort on it. loadedCount is not reset by retryOrg,
// so sorting stays active during retries.

// Invalidate frozen order when org membership changes (key is order-independent).
const currentKey = [...props.selectedOrgs].sort().join(",");
if (currentKey !== frozenOrgsKey) {
frozenOrder = null;
frozenOrgsKey = currentKey;
}

// Replay frozen order if available, appending any orgs not yet in the list.
if (frozenOrder !== null) {
const stateMap = new Map(states.map((s) => [s.org, s]));
const result: OrgRepoState[] = [];
for (const org of frozenOrder) {
const s = stateMap.get(org);
if (s) {
result.push(s);
stateMap.delete(org);
}
}
for (const s of stateMap.values()) result.push(s);
return result;
}

// Defer sorting until all orgs have loaded (prevents layout shift during trickle-in).
if (loadedCount() < props.selectedOrgs.length) return states;
// Order: personal org first, then remaining orgs alphabetically.
// Repos within each org retain their existing recency order from fetchRepos.
return [...states].sort((a, b) => {

// Guard against stale orgStates: the memo runs synchronously when selectedOrgs changes,
// but createEffect resets orgStates asynchronously, so stale entries may still be present.
// Return the stale list (not []) to avoid a flash-to-empty before the effect resets state.
const selectedOrgSet = new Set(props.selectedOrgs);
if (states.some((s) => !selectedOrgSet.has(s.org))) return states;

// Sort: personal org first, then alphabetically. Capture order to freeze it.
const sorted = [...states].sort((a, b) => {
const aIsUser = a.type === "user" ? 0 : 1;
const bIsUser = b.type === "user" ? 0 : 1;
if (aIsUser !== bIsUser) return aIsUser - bIsUser;
return a.org.localeCompare(b.org, "en");
});
frozenOrder = sorted.map((s) => s.org);
return sorted;
});

function toRepoRef(entry: RepoEntry): RepoRef {
Expand Down
50 changes: 50 additions & 0 deletions tests/acceptance/org-order-stability.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
Feature: Freeze org display order in RepoSelector after initial sort

The RepoSelector component sorts organizations (personal first, then
alphabetical) when all orgs finish loading. After the initial sort, the
order is frozen to prevent visual re-ordering on reactive updates like
repo retries or checkbox toggles. The frozen order is invalidated when
the set of selected organizations changes (e.g., granting access to a
new org or revoking access), triggering a fresh sort. Invalidation uses
a serialized sorted Set comparison, not length, to detect membership
changes even when the org count stays the same.

Background:
Given the user is authenticated with a GitHub account

Scenario: S1 - Org order remains stable after repo retry
Given the RepoSelector displays 3 orgs sorted as "alice", "acme-corp", "beta-org" with beta-org showing a Retry button
When the user clicks the Retry button on "beta-org" and the repos load successfully
Then the org header order remains "alice", "acme-corp", "beta-org"

Scenario: S2 - Org order remains stable when toggling a repo checkbox
Given the RepoSelector displays 3 orgs sorted as "alice", "acme-corp", "beta-org" with all repos loaded
When the user toggles a repo checkbox under "acme-corp"
Then the org header order remains "alice", "acme-corp", "beta-org"

Scenario: S3 - Frozen order invalidated when a new org is granted
Given the RepoSelector displays 2 orgs sorted as "alice", "delta-inc" with order frozen
When the user grants access to a new org "acme-corp" and it finishes loading
Then the org header order becomes "alice", "acme-corp", "delta-inc"

Scenario: S4 - Initial sort applies personal org first
Given the RepoSelector is displayed with 4 orgs "charlie", "acme-corp", "beta-org", "delta-inc" where "charlie" is the personal org
When all orgs finish loading
Then the org header order is "charlie", "acme-corp", "beta-org", "delta-inc"

Scenario: S5 - Org order stable in accordion layout after retry
Given the RepoSelector displays 7 orgs in accordion layout with "alice" first and "echo-labs" showing a Retry button
When the user clicks Retry on "echo-labs" and its repos load successfully
Then the org header order remains "alice", "acme-corp", "beta-org", "charlie-co", "delta-inc", "echo-labs", "foxtrot-io"
And the currently expanded accordion panel remains expanded

Scenario: S6 - New org appears in correct sorted position with 6+ orgs
Given the RepoSelector displays 6 orgs all loaded and sorted with "alice" as the personal org
When the user grants access to a new org "beta-org" and it finishes loading
Then the org header order becomes "alice", "acme-corp", "beta-org", "charlie-co", "delta-inc", "echo-labs", "foxtrot-io"

Scenario: S7 - Frozen order invalidated on simultaneous add and remove
Given the RepoSelector displays 3 orgs sorted as "alice", "acme-corp", "delta-inc" with order frozen
When the user's org access changes so that "delta-inc" is removed and "aaa-org" is added and aaa-org finishes loading
Then the org header order becomes "alice", "aaa-org", "acme-corp"
And "delta-inc" no longer appears in the list
Loading