Skip to content

Next: Call link onClick before preventing default - #35311

Merged
Sidnioulz merged 3 commits into
storybookjs:nextfrom
yatishgoel:fix/35213-nextjs-link-mock-onclick-order
Jun 29, 2026
Merged

Sidnioulz merged 3 commits into
storybookjs:nextfrom
yatishgoel:fix/35213-nextjs-link-mock-onclick-order

Conversation

@yatishgoel

@yatishgoel yatishgoel commented Jun 27, 2026 •

Copy link
Copy Markdown
Member

Closes #35213

What I did

The mocked next/link (used by @storybook/nextjs and @storybook/nextjs-vite) called event.preventDefault() before invoking the consumer-provided onClick. The real next/link does the opposite: it runs the consumer's onClick first and only navigates (and prevents the browser default) if that handler hasn't already called preventDefault().

Because the mock prevented default first, any handler that branches on event.defaultPrevented received an already-prevented event. A concrete case is Headless UI's Button as={Link}, whose merged click handler exits early when the event is already default-prevented - so the user's onClick never ran in Storybook, even though the same component works in a real Next.js app.

Changes:

  • Run the consumer's onClick (or the legacyBehavior child's onClick) before preventing default, mirroring the real next/link.
  • Only "navigate" (prevent the browser default and log the linkAction) when the event hasn't already been default-prevented, so a consumer that prevents default suppresses the mock navigation just like the real component.
  • Extracted the shared navigate logic into a single helper used by both the default and legacyBehavior paths. The behavior was previously duplicated in two places, which is why the bug existed in both.
  • Applied identically to both @storybook/nextjs and @storybook/nextjs-vite.

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Added regression tests to the existing index.test.tsx for both packages:

  • onClick is invoked before default is prevented (event.defaultPrevented is false when the handler runs).
  • A Headless-UI-style handler that bails when the event is already default-prevented still runs.

Manual testing

Caution

This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!

  1. Run a sandbox for a Next.js template, e.g. yarn task --task sandbox --start-from auto --template nextjs/default-ts (or nextjs-vite/default-ts).
  2. Open Storybook in your browser.
  3. Render a story whose component uses next/link with an onClick handler that branches on event.defaultPrevented (for example a Headless UI Button as={Link}, or any link whose onClick checks event.defaultPrevented).
  4. Click the link and confirm the consumer's onClick runs. Before this change the handler was skipped because the event arrived already default-prevented.

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Declare whether manual QA will be needed for this PR during the next release, through qa:needed or qa:skip

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the @storybookjs/core team here.

core team members can create a canary release here or locally with gh workflow run --repo storybookjs/storybook publish.yml --field pr=<PR_NUMBER>

Summary by CodeRabbit

  • Bug Fixes
    • Updated the next/link mock so custom onClick handlers run before navigation is triggered.
    • Navigation now only occurs when the click event hasn’t already been canceled, honoring preventDefault() consistently in both standard and legacy click flows.
  • Tests
    • Expanded next/link mock test coverage for click ordering and defaultPrevented behavior.
  • New Features
    • Added a Storybook story and an E2E scenario to verify next/link action logging appears in the Actions panel.

- Added new test cases to verify onClick behavior with default prevention.
- Refactored handleClick to ensure onClick is called before navigation.
- Introduced utility functions for creating click events and retrieving onClick handlers.
@coderabbitai

coderabbitai Bot commented Jun 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The Link mock click handling was updated in both Next.js package variants to call onClick before navigation and skip navigation when the event is already default-prevented. Action logging now includes next/link::Link, and Storybook plus E2E coverage was added for the logged link action.

Changes

Link click handling

Layer / File(s) Summary
Next.js Link click flow and tests
code/frameworks/nextjs/src/export-mocks/link/index.tsx, code/frameworks/nextjs/src/export-mocks/link/index.test.tsx
MockLink now routes click handling through navigate, and the tests exercise onClick order and already-prevented events.
Next.js Vite Link click flow and tests
code/frameworks/nextjs-vite/src/export-mocks/link/index.tsx, code/frameworks/nextjs-vite/src/export-mocks/link/index.test.tsx
The Vite mock applies the same click handling flow and adds matching test coverage for handler order and default-prevented clicks.
Link action logging and story coverage
code/core/src/actions/loaders.ts, code/frameworks/nextjs/template/stories/Link.stories.tsx, code/frameworks/nextjs-vite/template/stories/Link.stories.tsx, code/e2e-sandbox/framework-nextjs.spec.ts
next/link::Link is added to action logging, a TriggersLinkAction story renders the link, and the E2E spec verifies the action entry in the Actions panel.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes


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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
code/frameworks/nextjs/src/export-mocks/link/index.test.tsx (1)

23-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise the public click path instead of forwardRef.render.

(MockLink as any).render(props, null) relies on a non-public React wrapper field, and these new cases still miss the branches changed in code/frameworks/nextjs/src/export-mocks/link/index.tsx Lines 30-33 and 43-51 because they never render/click the legacyBehavior path or call preventDefault() from the consumer handler. Please drive the component through an actual render and add a prevented-click case so the defaultPrevented short-circuit is covered too.

Also applies to: 46-70

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/frameworks/nextjs/src/export-mocks/link/index.test.tsx` around lines 23
- 26, The link tests are bypassing the public click flow by calling
MockLink.render directly, so the updated branches in MockLink are not being
exercised. Update getAnchorOnClick and the related cases to render MockLink
normally and trigger clicks through the actual anchor element, including a
legacyBehavior path. Also add a test where the consumer click handler calls
preventDefault so the defaultPrevented short-circuit in MockLink is covered.
code/frameworks/nextjs-vite/src/export-mocks/link/index.test.tsx (1)

23-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise the public click path instead of forwardRef.render.

(MockLink as any).render(props, null) relies on a non-public React wrapper field, and these new cases still miss the branches changed in code/frameworks/nextjs-vite/src/export-mocks/link/index.tsx Lines 30-33 and 43-51 because they never render/click the legacyBehavior path or call preventDefault() from the consumer handler. Please drive the component through an actual render and add a prevented-click case so the defaultPrevented short-circuit is covered too.

Also applies to: 46-70

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/frameworks/nextjs-vite/src/export-mocks/link/index.test.tsx` around
lines 23 - 26, The link mock tests are using the non-public MockLink.render
path, which bypasses the real click behavior in MockLink and misses the updated
branches in the exported link mock. Update the tests to mount/render MockLink
through its public component API and trigger clicks on the rendered anchor so
the legacyBehavior path is exercised; also add a case where the consumer click
handler calls preventDefault() to cover the defaultPrevented short-circuit in
the click handler logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@code/frameworks/nextjs-vite/src/export-mocks/link/index.test.tsx`:
- Around line 23-26: The link mock tests are using the non-public
MockLink.render path, which bypasses the real click behavior in MockLink and
misses the updated branches in the exported link mock. Update the tests to
mount/render MockLink through its public component API and trigger clicks on the
rendered anchor so the legacyBehavior path is exercised; also add a case where
the consumer click handler calls preventDefault() to cover the defaultPrevented
short-circuit in the click handler logic.

In `@code/frameworks/nextjs/src/export-mocks/link/index.test.tsx`:
- Around line 23-26: The link tests are bypassing the public click flow by
calling MockLink.render directly, so the updated branches in MockLink are not
being exercised. Update getAnchorOnClick and the related cases to render
MockLink normally and trigger clicks through the actual anchor element,
including a legacyBehavior path. Also add a test where the consumer click
handler calls preventDefault so the defaultPrevented short-circuit in MockLink
is covered.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 12b79be4-cf40-4d31-9854-f686709b0fd6

📥 Commits

Reviewing files that changed from the base of the PR and between bf34cdb and 5a4fd2b.

📒 Files selected for processing (4)
  • code/frameworks/nextjs-vite/src/export-mocks/link/index.test.tsx
  • code/frameworks/nextjs-vite/src/export-mocks/link/index.tsx
  • code/frameworks/nextjs/src/export-mocks/link/index.test.tsx
  • code/frameworks/nextjs/src/export-mocks/link/index.tsx

@Sidnioulz Sidnioulz added nextjs ci:normal Run our default set of CI jobs (choose this for most PRs). qa:skip Pull Requests that do not need any QA. (e.g. documentation) bug labels Jun 29, 2026
@Sidnioulz Sidnioulz changed the title Enhance next/link mock tests and functionality Next: Call link onClick before preventing default Jun 29, 2026
@Sidnioulz Sidnioulz self-assigned this Jun 29, 2026
@Sidnioulz Sidnioulz moved this to In Progress in Core Team Projects Jun 29, 2026

@Sidnioulz Sidnioulz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code LGTM, I tested it and it makes sense and behaves as intended. Thanks for that!

There is one bug, and I don't know if it's a regression or not: the linkAction does not actually do anything. We're supposed to call a mock function with a custom mockName that would appear in the actions panel, but nothing appears.

I created this story file to debug, and it did show me an action entry when I manually called an equivalent mock function inside my story:

import Link from 'next/link';
import type { PartialStoryFn, StoryContext } from 'storybook/internal/types';
import { fn } from 'storybook/test';

const FooCmp = () => (
  <Link
    href="/about"
    onClick={(event) => {
      console.log('Foo link clicked');
      fn().mockName('This one will appear')(event)
      // event.preventDefault();
    }}
  >
    Foo
  </Link>
);

export default {
  component: FooCmp,
  title: 'Debug Foo',
  decorators: [
    (storyFn: PartialStoryFn, context: StoryContext) => {
      return storyFn();
    },
  ],
};

export const Foo = {};

Could you please find out why the framework's internal linkAction is having no effect when the event is not default prevented?

Ideally, could you please add stories to the template and an E2E test to ensure this does not regress?

@yatishgoel

Copy link
Copy Markdown
Member Author

Thanks for catching this! The linkAction fires fine; the issue is the Actions loader (core/src/actions/loaders.ts) only forwards next/*:: mocks on a hardcoded allowlist, and next/link::Link was missing from it, so the action got filtered out before reaching the panel. That's also why your manual fn().mockName(...) showed up but the built-in one didn't.

- Updated loaders to log actions for next/link.
- Added new test suite for next/link to verify link action logging on click.
- Introduced a new story for Link component to trigger link action.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
code/frameworks/nextjs-vite/template/stories/Link.stories.tsx (1)

75-77: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a play interaction for this new story.

This new story is being used as coverage for the link-action path, but it does not exercise the click behavior itself. Please add a play that clicks the link and asserts the expected canvas behavior, then verify it with the Storybook Vitest config. As per coding guidelines, **/*.stories.tsx: “add or update <Component>.stories.tsx and cover each behavior with play functions using expect, userEvent, and within from storybook/test” and “verify play assertions with vitest --config code/vitest.config.storybook.ts <story-file>.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/frameworks/nextjs-vite/template/stories/Link.stories.tsx` around lines
75 - 77, The new TriggersLinkAction story in Link.stories.tsx only renders the
link and does not exercise the click path, so add a play function for this story
that uses userEvent, within, and expect from storybook/test to click the Link
and assert the expected canvas state or outcome after the interaction. Keep the
behavior-specific assertion inside TriggersLinkAction so the story covers the
link-action flow end to end, and then verify the play test with the Storybook
Vitest config using code/vitest.config.storybook.ts for this story file.

Source: Coding guidelines

code/frameworks/nextjs/template/stories/Link.stories.tsx (1)

75-77: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a play interaction for this new story.

This new story is being used as coverage for the link-action path, but it does not exercise the click behavior itself. Please add a play that clicks the link and asserts the expected canvas behavior, then verify it with the Storybook Vitest config. As per coding guidelines, **/*.stories.tsx: “add or update <Component>.stories.tsx and cover each behavior with play functions using expect, userEvent, and within from storybook/test” and “verify play assertions with vitest --config code/vitest.config.storybook.ts <story-file>.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/frameworks/nextjs/template/stories/Link.stories.tsx` around lines 75 -
77, The new TriggersLinkAction story only renders the link and does not exercise
the click path, so add a play function to this story that uses within,
userEvent, and expect from storybook/test to click the Link and assert the
expected canvas state after the action. Keep the fix localized to
TriggersLinkAction in Link.stories.tsx, and then validate the interaction with
the Storybook Vitest configuration by running the story file through the
configured vitest command.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@code/frameworks/nextjs-vite/template/stories/Link.stories.tsx`:
- Around line 75-77: The new TriggersLinkAction story in Link.stories.tsx only
renders the link and does not exercise the click path, so add a play function
for this story that uses userEvent, within, and expect from storybook/test to
click the Link and assert the expected canvas state or outcome after the
interaction. Keep the behavior-specific assertion inside TriggersLinkAction so
the story covers the link-action flow end to end, and then verify the play test
with the Storybook Vitest config using code/vitest.config.storybook.ts for this
story file.

In `@code/frameworks/nextjs/template/stories/Link.stories.tsx`:
- Around line 75-77: The new TriggersLinkAction story only renders the link and
does not exercise the click path, so add a play function to this story that uses
within, userEvent, and expect from storybook/test to click the Link and assert
the expected canvas state after the action. Keep the fix localized to
TriggersLinkAction in Link.stories.tsx, and then validate the interaction with
the Storybook Vitest configuration by running the story file through the
configured vitest command.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 77f4c123-a15b-43f1-94f4-383c0787b4cc

📥 Commits

Reviewing files that changed from the base of the PR and between 5a4fd2b and 433d54d.

📒 Files selected for processing (4)
  • code/core/src/actions/loaders.ts
  • code/e2e-sandbox/framework-nextjs.spec.ts
  • code/frameworks/nextjs-vite/template/stories/Link.stories.tsx
  • code/frameworks/nextjs/template/stories/Link.stories.tsx

@Sidnioulz Sidnioulz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice! Good catch!

This was referenced Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-scan:human bug ci:normal Run our default set of CI jobs (choose this for most PRs). nextjs qa:skip Pull Requests that do not need any QA. (e.g. documentation)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[Bug]: @storybook/nextjs-vite next/link mock calls preventDefault before onClick

2 participants