Skip to content

fix(useProjectHomeHref): carry entity selection through Back-to-project links#228

Merged
damienriehl merged 4 commits intodevfrom
fix/back-to-project-preserves-selection
May 2, 2026
Merged

fix(useProjectHomeHref): carry entity selection through Back-to-project links#228
damienriehl merged 4 commits intodevfrom
fix/back-to-project-preserves-selection

Conversation

@damienriehl
Copy link
Copy Markdown
Contributor

@damienriehl damienriehl commented May 2, 2026

Closes #206. Supersedes the sessionStorage-breadcrumb design proposed in the issue body in favor of the surgical fix described in the closed sibling issue #227 — extend the existing useProjectHomeHref hook to read useSelectionStore (with URL fallback) and append buildSelectionQuery(...) to the resolved base URL.

Why this approach (not the issue's sessionStorage breadcrumb)

PR #104 already shipped the primitives this fix needs:

  • useSelectionStore — Zustand store with the active { iri, type }, populated by viewer + editor and read by ViewerEditorSwitcher.
  • selectionUrl utilities — buildSelectionQuery(), readSelectionFromSearchParams(), SELECTION_PARAM_BY_TYPE.
  • useProjectHomeHref itself — already wired into all five side pages, already factors in preferEditMode + permissions.

The sessionStorage proposal would add a parallel state channel that:

  • Duplicates useSelectionStore (which is already updated on every selection).
  • Loses the permission gate on read — a stored /editor URL would survive role revocation and route the user where they no longer have rights.
  • Introduces an SSR/hydration flash because <Link href> returns the fallback synchronously, then updates after useEffect reads sessionStorage.
  • Goes stale across branch switches, deletes, and tab navigation without any invalidation strategy.

Reusing the existing primitives gives selection round-trips for free across all five side-page consumers, with no new files, no SSR flash, and the permission-gated base URL preserved.

Diff summary

// lib/hooks/useProjectHomeHref.ts (extension)
const searchParams = useSearchParams();
const storeIri = useSelectionStore((s) => s.iri);
const storeType = useSelectionStore((s) => s.type);
const selection =
  storeIri && storeType
    ? { iri: storeIri, type: storeType }
    : readSelectionFromSearchParams(searchParams);

const base =
  preferEditMode && canSuggest
    ? `/projects/${projectId}/editor`
    : `/projects/${projectId}`;
return `${base}${buildSelectionQuery(selection)}`;

The store-first-then-URL-fallback pattern is copied verbatim from ViewerEditorSwitcher so behavior stays consistent across both surfaces.

Tests

New __tests__/lib/hooks/useProjectHomeHref.test.ts (9 cases):

  • Bare viewer URL when no selection.
  • Editor URL when preferEditMode is on and user can suggest.
  • Routes prefer-edit-mode users to the viewer when canSuggest is false (permission gate).
  • Appends class / property / individual IRI from the store (one case each).
  • Appends selection to the editor URL when preferEditMode is on.
  • Falls back to URL params when the store is empty.
  • Store wins over URL when both are set.

Verification:

  • npm run type-check — clean
  • npm run lint — 15 warnings, 0 errors (all pre-existing in unrelated files)
  • npx vitest run — 159 files / 2740 tests pass

Test plan

  • Open a project as an editor, select a class → click Settings → click Back to project → land back on viewer/editor with same class selected (URL has ?classIri=).
  • Same with Pull Requests, Analytics, Suggestions, Suggestions / Review side pages.
  • Same flow for property selection (?propertyIri=) and individual selection (?individualIri=).
  • Toggle prefer edit mode on → side-page back link points to /editor (with selection); toggle off → points to viewer (with selection).
  • Sign in as a viewer-role user → side-page back link routes to /projects/{id} (not /editor) regardless of preference.
  • Hard-refresh on a side page (no store state) → back link gracefully falls back to bare project URL.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Entity selections now persist across editor page navigation instead of being cleared on unmount.
  • New Features

    • Returning to project home now preserves the currently selected entity (class, property, or individual) in the URL for seamless navigation context.

…o-project links

Side-page back links (settings, PRs, analytics, suggestions, suggestions/review)
all route through useProjectHomeHref, which knew the right base URL (viewer vs
editor based on preferEditMode + permissions) but dropped the active
class/property/individual selection — so a user editing a class who opened
settings and clicked Back lost their place.

Mirror the pattern already in ViewerEditorSwitcher: read useSelectionStore
first (in-memory selection set by the viewer/editor the user came from), fall
back to URL params for first render or hard-deep-link cases, and append
buildSelectionQuery() to the resolved base URL.

Closes #206. Supersedes the sessionStorage-breadcrumb design proposed in the
issue body in favor of the surgical fix described in #227, reusing the
selectionStore + selectionUrl primitives PR #104 already established. No
sessionStorage parallel state, no SSR hydration flash, and the
permission-gated base URL is preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 2, 2026

Warning

Rate limit exceeded

@damienriehl has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 44 minutes and 5 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0d0735b8-3075-4945-854a-c0410e665676

📥 Commits

Reviewing files that changed from the base of the PR and between b4c1dcd and e9937f7.

📒 Files selected for processing (6)
  • __tests__/lib/hooks/useProjectHomeHref.test.ts
  • __tests__/lib/stores/selectionStore.test.ts
  • app/projects/[id]/editor/page.tsx
  • app/projects/[id]/page.tsx
  • lib/hooks/useProjectHomeHref.ts
  • lib/stores/selectionStore.ts
📝 Walkthrough

Walkthrough

The PR implements entity selection persistence across side-page navigation. Editor layouts no longer clear the selection store on unmount, allowing other pages to read stored selection state. The project home-return hook now appends selection query parameters to its returned URL, preserving the selected class/property/individual across navigation round-trips.

Changes

Selection Persistence Across Navigation

Layer / File(s) Summary
Selection Store Cleanup Removal
components/editor/developer/DeveloperEditorLayout.tsx, components/editor/standard/StandardEditorLayout.tsx
Removed useEffect unmount cleanup that called clearSelection(). Selection now persists in the store until overwritten by a subsequent mount, enabling cross-page navigation without losing selected IRIs.
URL Generation with Selection
lib/hooks/useProjectHomeHref.ts
Hook now resolves selection from useSelectionStore (when iri and type exist) or readSelectionFromSearchParams, and appends selection query params via buildSelectionQuery() to the returned home URL, preserving ?classIri=, ?propertyIri=, or ?individualIri= across navigation.
Selection Store Contract Tests
__tests__/components/editor/developer/DeveloperEditorLayout.test.tsx, __tests__/components/editor/standard/StandardEditorLayout.test.tsx
New "selection store cross-page contract" test blocks verify that mounting with selectedIri populates the store with iri and type: "class", and that unmounting preserves those values instead of clearing them.
Hook URL Behavior Tests
__tests__/lib/hooks/useProjectHomeHref.test.ts
Comprehensive test suite with hoisted module setup and full dependency mocking (useSession, useSearchParams, useProject, derivePermissions). Asserts routing logic (viewer vs editor base), selection query parameter encoding for all entity types, editor/viewer URL generation with selections, query source precedence (store over URL params), and fallback behavior when store is empty.

Sequence Diagram

sequenceDiagram
    actor User
    participant Viewer/Editor as Viewer/Editor<br/>(DeveloperEditorLayout or<br/>StandardEditorLayout)
    participant SelectionStore as SelectionStore
    participant SidePage as Side Page<br/>(Settings, PRs, etc.)
    participant ProjectHome as Project Home Link<br/>(useProjectHomeHref)

    User->>Viewer/Editor: Select class/property
    Viewer/Editor->>SelectionStore: setSelection(iri, type)
    SelectionStore-->>Viewer/Editor: Store updated
    
    User->>SidePage: Navigate to Settings/PRs
    Viewer/Editor-->>Viewer/Editor: Unmounts (no clearSelection)
    SelectionStore-->>SelectionStore: Persists selection value
    
    User->>ProjectHome: Click "Back to project"
    ProjectHome->>SelectionStore: Read selection (iri, type)
    ProjectHome->>ProjectHome: buildSelectionQuery(selection)
    ProjectHome-->>User: Navigate to /projects/[id]?classIri=...
    
    User->>Viewer/Editor: Re-mount at project URL
    Viewer/Editor->>SelectionStore: Check store value
    SelectionStore-->>Viewer/Editor: Selection preserved, iri still set
    Viewer/Editor-->>User: Render with prior selection active
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Suggested labels

enhancement, UX

Poem

🐰 A rabbit hops through pages wide,
Selection now will never hide—
From editor to settings' door,
The chosen class returns once more!
No unmount clears this precious state,
Navigation's swift, no longer late. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: extending useProjectHomeHref to preserve entity selection through back-to-project navigation links.
Linked Issues check ✅ Passed The PR implements selection preservation via useSelectionStore (avoiding sessionStorage), adds regression tests, and handles the mount-preserve/unmount-persist pattern correctly.
Out of Scope Changes check ✅ Passed All changes directly support selection preservation: useProjectHomeHref extension, layout unmount cleanup removal, and comprehensive tests. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/back-to-project-preserves-selection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 44 minutes and 5 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 2, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

The previous fix to useProjectHomeHref (5b46db2) was correct but ineffective:
both StandardEditorLayout and DeveloperEditorLayout had a useEffect cleanup
that called clearSelection() on unmount. When a user navigated from the editor
to a side page (settings, PRs, analytics, suggestions), the editor unmounted
*before* the side page rendered, wiping the store. By the time
useProjectHomeHref read it on the side page, iri/type were null and the
back-link fell through to the bare project URL.

Drop the unmount-clear in both layouts. The next editor mount overwrites the
store from its own URL params, so cross-project bleed is bounded to a single
render frame between mount and the setSelection effect — too small to matter
in practice (the user can't click in that window).

Adds two regression tests per layout: one asserts the mount populates the
store from props; one asserts the store still holds the value after unmount,
locking in the contract that side-page Back links depend on.

Closes #206 (in combination with 5b46db2). Verified manually: editor → select
class → settings → Back lands back on the editor with the class re-selected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@damienriehl
Copy link
Copy Markdown
Contributor Author

Follow-up: drop unmount-clear in editor layouts (b4c1dcd)

Manual test exposed that the first commit was correct but ineffective. Both StandardEditorLayout and DeveloperEditorLayout had a useEffect(() => () => clearSelection(), [clearSelection]) that wiped the store on unmount — so when the user navigated editor → settings, the editor unmounted before the side page rendered, and useProjectHomeHref read null/null.

This commit:

  • Drops the unmount-clear in both layouts. The next editor mount's setSelection effect overwrites from URL params, so cross-project bleed is bounded to one render frame.
  • Adds two regression tests per layout — one asserts the mount populates the store, one asserts the store survives unmount. 4 new tests total; full suite now 2744/2744 passing.

The original useProjectHomeHref change (5b46db2) stays — it's still needed; this just unblocks it.

Verified manually after restart: editor → select class → Settings → Back to project lands on /projects/{id}/editor?classIri=… with the class re-selected.

Manual test: editor → settings → Back was landing on viewer because the hook
routed by global preferEditMode (default false), not by the mode the user
actually came from. A user who explicitly switched to the editor mid-session
without flipping the preference lost the editor state on round-trip.

Track the most recent surface in the existing selectionStore: editor and
viewer pages both call setMode() on mount. useProjectHomeHref now prefers
storeMode over preferEditMode, falling back to the preference only on cold
loads (e.g. deep-linked side page with no prior project surface visited).

Permission gate is preserved: even when storeMode says 'editor', a user
without canSuggest is still routed to the viewer.

5 new tests cover the new contract:
- selectionStore: setMode independence from selection, clear() resets mode.
- useProjectHomeHref: storeMode 'editor' overrides preferEditMode false;
  storeMode 'viewer' overrides preferEditMode true; permission gate still
  applies when storeMode says 'editor' but canSuggest is false.

Full suite: 2749/2749 passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@damienriehl
Copy link
Copy Markdown
Contributor Author

Follow-up: route Back to the mode the user actually came from (206173d)

Manual test exposed a third gap: editor → settings → Back to project was landing on the viewer. Reason: the hook was routing by the global preferEditMode preference (default false), not by the mode the user was actually in. A user who explicitly switched to the editor mid-session via the switcher (without ever flipping the preference) lost the editor state on the round-trip.

This commit:

  • Adds mode: 'viewer' | 'editor' | null to useSelectionStore (in-session, not persisted).
  • Editor and viewer pages call setMode(...) on mount.
  • useProjectHomeHref prefers stored mode over preferEditMode, falling back only on cold-load side-page deep links.
  • Permission gate preserved — canSuggest === false still forces the viewer URL even when stored mode says 'editor'.

5 new tests:

  • selectionStoresetMode independence from selection; clear() resets mode.
  • useProjectHomeHref — stored mode beats preference both ways; permission gate still applies.

Full suite 2749/2749. Verified in browser: editor → Settings → Back lands on /editor with the class re-selected, regardless of preferEditMode. Same for viewer round-trips.

…nch sync

BranchSelector fires onBranchChange on mount whenever a current branch is
known — that triggered handleBranchChange in the editor page, which used
router.replace(\`\${pathname}?branch=\${name}\`), wiping every other query
param. Effect: Back-to-project from a side page would briefly land the user
on /editor?classIri=... — but a render later, the mount-time branch sync
stripped classIri and the editor showed no selection.

Merge into existing search params instead of replacing them. branch is the
only key we touch; everything else (classIri/propertyIri/individualIri/
resumeSession) survives the round-trip.

Closes #206 (in combination with 5b46db2, b4c1dcd, 206173d).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@damienriehl
Copy link
Copy Markdown
Contributor Author

Follow-up: don't strip classIri on mount-time branch sync (e9937f7)

Manual test exposed a fourth gap. The editor page server log showed the right sequence on Back from settings:

```
GET /projects/X/settings 200 ← user clicked Settings
GET /projects/X/editor?classIri=Y ← Back link landed correctly with selection ✓
GET /projects/X/editor?branch=main ← then router.replace stripped classIri
```

`BranchSelector` fires `onBranchChange?.(currentBranch)` on mount whenever a current branch is known. That triggered `handleBranchChange` in the editor page (line 705), which did `router.replace(\`${pathname}?branch=${branchName}\`)` — wiping every other query param including `classIri`. So the user briefly landed with the right selection, then the mount-time branch sync nuked it.

Fix: merge `branch` into existing `URLSearchParams` instead of replacing the whole query string. `classIri` / `propertyIri` / `individualIri` / `resumeSession` all survive the round-trip now.

Type-check clean, full suite still 2749/2749. The viewer page has no equivalent rewrite path, which is why viewer→settings→back was already working — only the editor needed this patch.

@damienriehl damienriehl merged commit 795c973 into dev May 2, 2026
13 checks passed
@damienriehl damienriehl deleted the fix/back-to-project-preserves-selection branch May 2, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

"Back to project" doesn't preserve viewer/editor state (selection, mode, active tab)

2 participants