Skip to content

fix: memoize navigation config and callback to prevent stale closure in page-mode row clicks#909

Merged
hotlong merged 2 commits intomainfrom
copilot/fix-default-navigation-mode
Feb 28, 2026
Merged

fix: memoize navigation config and callback to prevent stale closure in page-mode row clicks#909
hotlong merged 2 commits intomainfrom
copilot/fix-default-navigation-mode

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Feb 28, 2026

Row clicks with default navigation.mode: 'page' silently do nothing due to a stale closure chain in ObjectView.tsx. Three unstable references compound: the { mode: 'page' } fallback object literal, the inline onNavigate arrow, and the resulting navOverlay — all recreated every render, causing useNavigationOverlay's handleClick to capture stale/undefined onNavigate.

Changes

  • apps/console/src/components/ObjectView.tsx — Wrap detailNavigation in useMemo and extract onNavigate into useCallback with [navigate, viewId] deps:
const detailNavigation = useMemo(
    () => activeView?.navigation ?? objectDef.navigation ?? { mode: 'page' },
    [activeView?.navigation, objectDef.navigation]
);

const handleNavOverlayNavigate = useCallback(
    (recordId: string | number, action?: string) => {
        if (action === 'new_window') { /* ... */ }
        if (action === 'view' || !action || action === 'page') {
            viewId ? navigate(`../../record/${String(recordId)}`, { relative: 'path' })
                   : navigate(`record/${String(recordId)}`);
        }
    },
    [navigate, viewId]
);
  • packages/react/src/__tests__/useNavigationOverlay.test.ts — Two new tests: verify handleClick picks up a replaced onNavigate after rerender, and verify navigation survives config transition from undefined{ mode: 'page' }.

  • ROADMAP.md — Documented root cause and fix.

Original prompt

This section details on the original issue you should resolve

<issue_title>Bug: Default Navigation Mode (Page) Clicks Have No Effect — Stale Closure Root Cause</issue_title>
<issue_description>

Summary

Clicking a row in the grid/list when the default navigation mode is Page does nothing. This has been reported and patched multiple times but the underlying root cause was never addressed. The bug is a stale closure chain caused by unmemoized objects/callbacks passed into useNavigationOverlay.

Screenshot

Navigation mode dropdown showing Page is selected but clicks do nothing

Root Cause Analysis (3 Compounding Issues)

Issue 1: { mode: 'page' } fallback creates a new object every render

In apps/console/src/components/ObjectView.tsx line 423:

const detailNavigation: ViewNavigationConfig = activeView?.navigation ?? objectDef.navigation ?? { mode: 'page' };

{ mode: 'page' } is a new object literal on every render. This causes the navigation option passed to useNavigationOverlay to change reference every render, which in turn causes its internal handleClick useCallback to be recreated every render.

Issue 2: onNavigate callback is not memoized

In apps/console/src/components/ObjectView.tsx lines 428-445, the onNavigate is an inline arrow function:

const navOverlay = useNavigationOverlay({
    navigation: detailNavigation,
    objectName: objectDef.name,
    onNavigate: (recordId: string | number, action?: string) => {
        // inline — new function identity every render
        if (action === 'view' || !action || action === 'page') {
            navigate(`record/${String(recordId)}`);
        }
    },
});

Because both navigation AND onNavigate have unstable identities, React may batch-skip the render where handleClick picks up the fresh onNavigate, resulting in a stale (or initial undefined) closure being called.

Issue 3: Cascade instability through renderListView

navOverlay (returned from useMemo) gets a new reference every render because its deps (handleClick, etc.) changed. This propagates through renderListView's useCallback deps, causing the <ListView> to re-render with potentially stale intermediate closures during React's batched updates.

Call Chain Trace

User clicks grid row
  → ObjectGrid.onRowClick
    → PluginObjectView.handleRowClick (line 397)
      → Console's onRowClick lambda (line 840)
        → navOverlay.handleClick
          → useNavigationOverlay.handleClick (line 120)
            → mode === 'page' → calls onNavigate(recordId, 'view')
              → ⚠️ onNavigate may be stale/undefined due to unmemoized deps

Proposed Fix

Memoize the navigation config and onNavigate callback in apps/console/src/components/ObjectView.tsx:

// 1. Memoize the fallback navigation config
const detailNavigation: ViewNavigationConfig = useMemo(
    () => activeView?.navigation ?? objectDef.navigation ?? { mode: 'page' },
    [activeView?.navigation, objectDef.navigation]
);

// 2. Memoize the onNavigate callback
const handleNavOverlayNavigate = useCallback(
    (recordId: string | number, action?: string) => {
        if (action === 'new_window') {
            const basePath = window.location.pathname.replace(/\/view\/.*$/, '');
            window.open(`${basePath}/record/${String(recordId)}`, '_blank');
            return;
        }
        if (action === 'view' || !action || action === 'page') {
            if (viewId) {
                navigate(`../../record/${String(recordId)}`, { relative: 'path' });
            } else {
                navigate(`record/${String(recordId)}`);
            }
        }
    },
    [navigate, viewId]
);

// 3. Use memoized values
const navOverlay = useNavigationOverlay({
    navigation: detailNavigation,
    objectName: objectDef.name,
    onNavigate: handleNavOverlayNavigate,
});

Files Affected

File Change
apps/console/src/components/ObjectView.tsx Memoize detailNavigation with useMemo, extract onNavigate into useCallback
packages/react/src/__tests__/useNavigationOverlay.test.ts Add test for stale closure scenario
ROADMAP.md Document fix

Acceptance Criteria

  • Clicking a grid row with navigation.mode: 'page' (default) navigates to record detail page
  • Clicking a grid row with navigation.mode: 'drawer' opens drawer overlay
  • Clicking a grid row with navigation.mode: 'modal' opens modal overlay
  • Clicking a grid row with navigation.mode: 'none' does nothing
  • All existing useNavigationOverlay tests pass (29 tests)
  • All existing ObjectView tests pass (32 tests)
  • New test: verify handleClick uses latest onNavigate after re-render
  • Update ROADMAP.md with root cause and fix

Labels

bug, P0, navigation</issue_description>

Comments on the Issue (you are @copilot in this section)

<comments...


🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectui Ready Ready Preview, Comment Feb 28, 2026 9:07am
objectui-demo Ready Ready Preview, Comment Feb 28, 2026 9:07am
objectui-storybook Ready Ready Preview, Comment Feb 28, 2026 9:07am

Request Review

… in page navigation

Memoize the fallback navigation config with useMemo and extract the
onNavigate callback into useCallback to ensure stable references.
This prevents the stale closure chain that caused default page-mode
row clicks to have no effect.

Add 2 stale-closure prevention tests to useNavigationOverlay.test.ts.
Update ROADMAP.md with root cause analysis and fix documentation.

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix clicks in default navigation mode when set to Page fix: memoize navigation config and callback to prevent stale closure in page-mode row clicks Feb 28, 2026
@hotlong hotlong marked this pull request as ready for review February 28, 2026 09:04
Copilot AI review requested due to automatic review settings February 28, 2026 09:04
@hotlong hotlong merged commit 9e599a4 into main Feb 28, 2026
3 of 4 checks passed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes Console row-click navigation when navigation.mode falls back to the default ('page') by stabilizing references passed into useNavigationOverlay, preventing stale/incorrect handler closures during rerenders.

Changes:

  • Memoize detailNavigation and extract onNavigate into a useCallback in apps/console/src/components/ObjectView.tsx.
  • Add two regression tests around rerender/option transitions in packages/react/src/__tests__/useNavigationOverlay.test.ts.
  • Document the root cause and fix in ROADMAP.md.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
apps/console/src/components/ObjectView.tsx Stabilizes navigation config + onNavigate callback identities passed to useNavigationOverlay.
packages/react/src/tests/useNavigationOverlay.test.ts Adds rerender-focused tests intended to guard against stale-closure regressions.
ROADMAP.md Adds a bug-fix entry describing the stale-closure chain and the mitigation.

Comment on lines +520 to +526
const { result, rerender } = renderHook(
({ onNavigate }) =>
useNavigationOverlay({
navigation: { mode: 'page' },
objectName: 'contacts',
onNavigate,
}),
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

In the rerender test, navigation: { mode: 'page' } is created inline inside the hook callback, so the navigation object reference changes on every rerender. That means this test will still pass even if handleClick only updates because navigation changed, so it doesn’t reliably prove that onNavigate updates are picked up independently. Consider hoisting the navigation object to a stable const (or passing it via props) so the rerender only changes onNavigate.

Copilot uses AI. Check for mistakes.
Comment on lines +548 to +559
it('should use latest onNavigate after navigation config changes from undefined to page', () => {
const onNavigate = vi.fn();

const { result, rerender } = renderHook(
({ navigation }) =>
useNavigationOverlay({
navigation,
objectName: 'contacts',
onNavigate,
}),
{ initialProps: { navigation: undefined as NavigationConfig | undefined } }
);
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

The test name mentions "use latest onNavigate" but onNavigate never changes in this test; it’s validating behavior across a navigation prop transition (undefined → page). Renaming the test (or adjusting it to actually vary onNavigate) would make the intent clearer and help prevent confusion when maintaining these tests.

Copilot uses AI. Check for mistakes.
Comment on lines 438 to 447
// page / view mode — navigate to record detail page
// Handles action === 'view' (from useNavigationOverlay page mode) and
// default fallthrough for any unrecognised action
if (action === 'view' || !action || action === 'page') {
if (viewId) {
navigate(`../../record/${String(recordId)}`, { relative: 'path' });
} else {
navigate(`record/${String(recordId)}`);
}
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

useNavigationOverlay calls onNavigate(recordId, navigation.view ?? 'view') for page mode. If a view is configured (e.g. 'detail_view'), action will be that string, but this handler only navigates for 'view' | 'page' | undefined, so clicks will silently do nothing. Consider treating any non-new_window action as a page navigation (or explicitly handling the view value).

Copilot uses AI. Check for mistakes.
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.

Bug: Default Navigation Mode (Page) Clicks Have No Effect — Stale Closure Root Cause

3 participants