Skip to content

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

Description

@JohnRDOrazio

Problem

When the user navigates from the viewer or editor to a sibling page (Project Settings, Pull Requests, Analytics, Suggestions, Dashboard) and then clicks Back to project, the state of the viewer/editor they came from is lost:

  • The viewer-vs-editor mode they were in resets to viewer (the bare URL).
  • The selected class/property/individual is dropped.
  • The active entity tab (Classes / Properties / Individuals) resets to Classes.
  • (Branch is preserved because it lives in sessionStorage; editor mode Standard/Developer is preserved because it lives in the editor-mode Zustand store.)

This makes round-trips through Settings or PRs feel like a hard reset of the user's place in the ontology.

Where the bug is

Every "Back to project" / "Back to projects" link in the side pages is a hard-coded Link to a bare URL:

  • app/projects/[id]/settings/page.tsx:910, 950href={\/projects/${projectId}`}`
  • app/projects/[id]/pull-requests/page.tsx:75 — same
  • app/projects/[id]/analytics/page.tsx:48 — same
  • app/projects/[id]/dashboard/page.tsx, app/projects/[id]/suggestions/page.tsx, app/projects/[id]/suggestions/review/page.tsx, app/projects/[id]/pull-requests/[prNumber]/page.tsx — comparable links to /

None of them carry the selection (?classIri= / ?propertyIri= / ?individualIri=) or the mode (/editor vs the bare path).

Proposed approach

Persist the user's most recent in-project URL (path + search params) per-project to sessionStorage, and have a small useProjectReturnHref(projectId) hook return that URL (with a sensible fallback to /projects/${projectId}).

1. Hook implementation

// lib/hooks/useProjectReturnHref.ts
const STORAGE_KEY = (projectId: string) => `ontokit:lastProjectUrl:${projectId}`;

/** Track the current viewer/editor URL so cross-page nav can return to it. */
export function useTrackProjectReturnUrl(projectId: string) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  useEffect(() => {
    if (!pathname) return;
    // Only track viewer or editor pages — not settings / PRs / etc.
    const isProjectPage =
      pathname === `/projects/${projectId}` ||
      pathname === `/projects/${projectId}/editor`;
    if (!isProjectPage) return;
    const search = searchParams.toString();
    const url = search ? `${pathname}?${search}` : pathname;
    try { sessionStorage.setItem(STORAGE_KEY(projectId), url); } catch {}
  }, [projectId, pathname, searchParams]);
}

/** Return the last-known viewer/editor URL for this project, or fallback. */
export function useProjectReturnHref(projectId: string): string {
  const fallback = `/projects/${projectId}`;
  const [href, setHref] = useState(fallback);
  useEffect(() => {
    try {
      const stored = sessionStorage.getItem(STORAGE_KEY(projectId));
      if (stored) setHref(stored);
    } catch {}
  }, [projectId]);
  return href;
}

2. Wiring

In app/projects/[id]/page.tsx and app/projects/[id]/editor/page.tsx, call useTrackProjectReturnUrl(projectId) so every URL change is mirrored to sessionStorage. The selection-store sync I already added in this PR fires on render anyway, so this is one more cheap effect.

In each side page (Settings, PRs, Analytics, Dashboard, Suggestions), replace the hard-coded href={\/projects/${projectId}`}on the **Back to project** link withhref={useProjectReturnHref(projectId)}`.

3. Fallback behavior

  • First visit (no stored URL): the hook returns /projects/${projectId}, which is today's behavior. Zero regression for users without prior session state.
  • Cross-tab: sessionStorage is per-tab, so each tab tracks its own most-recent state. Closing and reopening a tab loses the breadcrumb — acceptable.
  • Server render: hook returns the fallback synchronously (no sessionStorage on the server) and updates after mount. The link briefly points to the fallback for ~1 frame, then resolves to the stored URL. Good enough; we can polish later if it shows.

Done criteria

  • Selecting a class in the editor, navigating to Settings, and clicking Back to project lands back in the editor with the same class selected.
  • Same for property and individual selection.
  • Same when starting from the viewer instead of the editor.
  • First-visit / no-history fallback to /projects/${projectId} is unchanged.
  • No regression for users who deep-link directly to a side page (Back goes to the bare project URL because no breadcrumb was stored).

Out of scope

  • Restoring the active entity tab (Classes / Properties / Individuals) explicitly via URL. The selection store + the URL's ?<type>Iri= already pin the active tab indirectly via the in-page entityNavigationRef dispatch. If that turns out to be insufficient (e.g., user navigated to Properties tab without selecting a property), we can add an &tab=properties hint in a follow-up.
  • Restoring the editor's expanded class-tree state. Tree expansion is local component state; persisting it is a separate, larger feature.
  • Cross-tab restoration via localStorage. Out of scope; sessionStorage is the right scope for "where I was just now".

Related

  • PR feat: preserve entity selection across viewer/editor modes #104 (this branch) — established the URL contract for entity selection (?classIri= / ?propertyIri= / ?individualIri=) and the viewer/editor switcher. This bug is a sibling concern that became visible once selection-via-URL started actually working.
  • Could borrow the useSelectionStore pattern from this PR — but the return-URL is broader than just selection (also mode + tab), so a separate per-project breadcrumb is cleaner than overloading the selection store.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions