Skip to content

refactor(react): Simplify onError and onSuccess handlers in lazy loading#1945

Draft
manudeli wants to merge 3 commits intofeat/stable-lazyfrom
feat/stable-lazy-simbol
Draft

refactor(react): Simplify onError and onSuccess handlers in lazy loading#1945
manudeli wants to merge 3 commits intofeat/stable-lazyfrom
feat/stable-lazy-simbol

Conversation

@manudeli
Copy link
Copy Markdown
Member

@manudeli manudeli commented Apr 3, 2026

No description provided.

@manudeli manudeli self-assigned this Apr 3, 2026
@coauthors
Copy link
Copy Markdown

coauthors Bot commented Apr 3, 2026

People can be co-author:

Candidate Reasons Count Add this as commit message
@Copilot #1945 (comment) #1945 (comment) #1945 (comment) #1945 (comment) 4 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@manudeli #1945 1 Co-authored-by: manudeli <61593290+manudeli@users.noreply.github.com>

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 3, 2026

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

Project Deployment Actions Updated (UTC)
suspensive-next-streaming-react-query Ready Ready Preview, Comment Apr 3, 2026 11:29am
v2.suspensive.org Ready Ready Preview, Comment Apr 3, 2026 11:29am
v3.suspensive.org Ready Ready Preview, Comment Apr 3, 2026 11:29am
visualization.suspensive.org Ready Ready Preview, Comment Apr 3, 2026 11:29am

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 3, 2026

⚠️ No Changeset found

Latest commit: 73fcb86

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Copy Markdown

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

This PR refactors the React lazy utilities to simplify the exposed onSuccess/onError callback shapes while still enabling reloadOnError to identify the correct retry storage key.

Changes:

  • Simplified LazyOptions callback signatures and introduced a symbol-keyed metadata channel for passing the retry storage key internally.
  • Updated reloadOnError to use the new symbol-keyed storage key rather than receiving load directly.
  • Adjusted unit tests and documentation examples to reflect the new callback shapes.

Reviewed changes

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

File Description
packages/react/src/lazy.ts Refactors callback typing/callback composition and changes how reloadOnError derives its storage key.
packages/react/src/lazy.spec.tsx Updates expectations for onError/onSuccess calls to include the symbol-keyed storage key metadata.
docs/suspensive.org/src/content/ko/docs/react/lazy.mdx Updates example callback signatures to remove load from handler params.
docs/suspensive.org/src/content/en/docs/react/lazy.mdx Updates example callback signatures to remove load from handler params.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 6 to 9
interface LazyOptions {
onSuccess?: ({ load }: { load: () => Promise<{ default: ComponentType<any> }> }) => void
onError?: ({ error, load }: { error: unknown; load: () => Promise<{ default: ComponentType<any> }> }) => void
onSuccess?: () => void
onError?: (options: { error: unknown }) => void
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

LazyOptions['onSuccess'] is typed as () => void, but createLazy always calls it with an argument (currently an object keyed by reloadOnErrorStorageKeyAccessKeySymbol), forcing multiple unsafe casts. This also makes reloadOnError return a callback that expects an argument but is typed as no-arg, so calling it per the type would yield an undefined storage key. Consider changing onSuccess/onError types to accept an optional metadata argument (including the symbol-keyed storageKey) so createLazy and reloadOnError can be implemented without any/casts and the contract matches runtime behavior.

Copilot uses AI. Check for mistakes.
Comment thread packages/react/src/lazy.ts Outdated
Comment on lines +52 to +56
const storageKey = load.toString()

const composedOnSuccess = () => {
options?.onSuccess?.({ load })
defaultOptions.onSuccess?.({ load })
;(options?.onSuccess as undefined | ((arg: { [reloadOnErrorStorageKeyAccessKeySymbol]: string }) => void))?.({
[reloadOnErrorStorageKeyAccessKeySymbol]: storageKey,
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

const storageKey = load.toString() is evaluated eagerly for every lazy component creation, even when no callbacks are provided. Since Function#toString() can be relatively expensive and can generate large strings, consider computing the storage key lazily (only inside composedOnSuccess/composedOnError when at least one callback is defined) to avoid unnecessary work.

Copilot uses AI. Check for mistakes.
Comment on lines +210 to +214
onSuccess: ((arg: any) => {
;(options.onSuccess as any)?.(arg)
const storageKey = arg?.[reloadOnErrorStorageKeyAccessKeySymbol] as string
reloadStorage.removeItem(storageKey)
}) as LazyOptions['onSuccess'],
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

reloadOnError's onSuccess reads arg?.[reloadOnErrorStorageKeyAccessKeySymbol] and passes it to removeItem without validating it. Because the returned callback is typed as () => void, a consumer could legally invoke it with no args and cause removeItem to receive undefined (or the string 'undefined' for DOM storage). Add a runtime guard (e.g., no-op or throw with a clear error) and/or adjust the callback type so the storage key is required when invoked internally.

Copilot uses AI. Check for mistakes.
Comment thread packages/react/src/lazy.ts Outdated
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 3, 2026

Size Change: +112 B (+0.19%)

Total Size: 60 kB

Filename Size Change
packages/react/dist/lazy.cjs 2.02 kB +53 B (+2.69%)
packages/react/dist/lazy.mjs 1.96 kB +59 B (+3.1%)
ℹ️ View Unchanged
Filename Size
packages/jotai/dist/Atom.cjs 317 B
packages/jotai/dist/Atom.mjs 249 B
packages/jotai/dist/AtomValue.cjs 305 B
packages/jotai/dist/AtomValue.mjs 236 B
packages/jotai/dist/index.cjs 174 B
packages/jotai/dist/index.mjs 88 B
packages/jotai/dist/SetAtom.cjs 303 B
packages/jotai/dist/SetAtom.mjs 234 B
packages/next/dist/index.cjs 305 B
packages/next/dist/index.mjs 250 B
packages/next/dist/react-******.cjs 268 B
packages/next/dist/react-******.mjs 213 B
packages/react-query-4/dist/createGetQueryClient.cjs 1.09 kB
packages/react-query-4/dist/createGetQueryClient.mjs 1.01 kB
packages/react-query-4/dist/index.cjs 446 B
packages/react-query-4/dist/index.mjs 298 B
packages/react-query-4/dist/infiniteQueryOptions.cjs 357 B
packages/react-query-4/dist/infiniteQueryOptions.mjs 292 B
packages/react-query-4/dist/IsFetching.cjs 491 B
packages/react-query-4/dist/IsFetching.mjs 413 B
packages/react-query-4/dist/Mutation.cjs 412 B
packages/react-query-4/dist/Mutation.mjs 332 B
packages/react-query-4/dist/mutationOptions.cjs 187 B
packages/react-query-4/dist/mutationOptions.mjs 134 B
packages/react-query-4/dist/PrefetchInfiniteQuery.cjs 463 B
packages/react-query-4/dist/PrefetchInfiniteQuery.mjs 387 B
packages/react-query-4/dist/PrefetchQuery.cjs 453 B
packages/react-query-4/dist/PrefetchQuery.mjs 379 B
packages/react-query-4/dist/QueriesHydration.cjs 1.66 kB
packages/react-query-4/dist/QueriesHydration.mjs 1.56 kB
packages/react-query-4/dist/QueryClientConsumer.cjs 353 B
packages/react-query-4/dist/QueryClientConsumer.mjs 277 B
packages/react-query-4/dist/queryOptions.cjs 353 B
packages/react-query-4/dist/queryOptions.mjs 286 B
packages/react-query-4/dist/SuspenseInfiniteQuery.cjs 668 B
packages/react-query-4/dist/SuspenseInfiniteQuery.mjs 580 B
packages/react-query-4/dist/SuspenseQueries.cjs 569 B
packages/react-query-4/dist/SuspenseQueries.mjs 483 B
packages/react-query-4/dist/SuspenseQuery.cjs 654 B
packages/react-query-4/dist/SuspenseQuery.mjs 568 B
packages/react-query-4/dist/usePrefetchInfiniteQuery.cjs 462 B
packages/react-query-4/dist/usePrefetchInfiniteQuery.mjs 395 B
packages/react-query-4/dist/usePrefetchQuery.cjs 452 B
packages/react-query-4/dist/usePrefetchQuery.mjs 388 B
packages/react-query-4/dist/useSuspenseInfiniteQuery.cjs 375 B
packages/react-query-4/dist/useSuspenseInfiniteQuery.mjs 305 B
packages/react-query-4/dist/useSuspenseQueries.cjs 368 B
packages/react-query-4/dist/useSuspenseQueries.mjs 299 B
packages/react-query-4/dist/useSuspenseQuery.cjs 365 B
packages/react-query-4/dist/useSuspenseQuery.mjs 298 B
packages/react-query-5/dist/createGetQueryClient.cjs 1.09 kB
packages/react-query-5/dist/createGetQueryClient.mjs 1.01 kB
packages/react-query-5/dist/index.cjs 438 B
packages/react-query-5/dist/index.mjs 294 B
packages/react-query-5/dist/infiniteQueryOptions.cjs 352 B
packages/react-query-5/dist/infiniteQueryOptions.mjs 286 B
packages/react-query-5/dist/IsFetching.cjs 445 B
packages/react-query-5/dist/IsFetching.mjs 366 B
packages/react-query-5/dist/Mutation.cjs 412 B
packages/react-query-5/dist/Mutation.mjs 332 B
packages/react-query-5/dist/mutationOptions.cjs 350 B
packages/react-query-5/dist/mutationOptions.mjs 284 B
packages/react-query-5/dist/PrefetchInfiniteQuery.cjs 466 B
packages/react-query-5/dist/PrefetchInfiniteQuery.mjs 391 B
packages/react-query-5/dist/PrefetchQuery.cjs 459 B
packages/react-query-5/dist/PrefetchQuery.mjs 383 B
packages/react-query-5/dist/QueriesHydration.cjs 1.66 kB
packages/react-query-5/dist/QueriesHydration.mjs 1.56 kB
packages/react-query-5/dist/QueryClientConsumer.cjs 351 B
packages/react-query-5/dist/QueryClientConsumer.mjs 276 B
packages/react-query-5/dist/queryOptions.cjs 347 B
packages/react-query-5/dist/queryOptions.mjs 281 B
packages/react-query-5/dist/SuspenseInfiniteQuery.cjs 668 B
packages/react-query-5/dist/SuspenseInfiniteQuery.mjs 580 B
packages/react-query-5/dist/SuspenseQueries.cjs 585 B
packages/react-query-5/dist/SuspenseQueries.mjs 498 B
packages/react-query-5/dist/SuspenseQuery.cjs 645 B
packages/react-query-5/dist/SuspenseQuery.mjs 558 B
packages/react-query-5/dist/usePrefetchInfiniteQuery.cjs 367 B
packages/react-query-5/dist/usePrefetchInfiniteQuery.mjs 300 B
packages/react-query-5/dist/usePrefetchQuery.cjs 364 B
packages/react-query-5/dist/usePrefetchQuery.mjs 294 B
packages/react-query-5/dist/useSuspenseInfiniteQuery.cjs 368 B
packages/react-query-5/dist/useSuspenseInfiniteQuery.mjs 299 B
packages/react-query-5/dist/useSuspenseQueries.cjs 363 B
packages/react-query-5/dist/useSuspenseQueries.mjs 294 B
packages/react-query-5/dist/useSuspenseQuery.cjs 359 B
packages/react-query-5/dist/useSuspenseQuery.mjs 292 B
packages/react-query/dist/index.cjs 383 B
packages/react-query/dist/index.mjs 201 B
packages/react-query/dist/v4.cjs 383 B
packages/react-query/dist/v4.mjs 201 B
packages/react-query/dist/v5.cjs 383 B
packages/react-query/dist/v5.mjs 201 B
packages/react/dist/ClientOnly.cjs 606 B
packages/react/dist/ClientOnly.mjs 536 B
packages/react/dist/DefaultProps.cjs 968 B
packages/react/dist/DefaultProps.mjs 901 B
packages/react/dist/Delay.cjs 985 B
packages/react/dist/Delay.mjs 906 B
packages/react/dist/ErrorBoundary.cjs 2.1 kB
packages/react/dist/ErrorBoundary.mjs 2.04 kB
packages/react/dist/ErrorBoundaryGroup.cjs 1.11 kB
packages/react/dist/ErrorBoundaryGroup.mjs 1.04 kB
packages/react/dist/index.cjs 342 B
packages/react/dist/index.mjs 229 B
packages/react/dist/Suspense.cjs 801 B
packages/react/dist/Suspense.mjs 716 B
packages/react/dist/useIsClient.cjs 295 B
packages/react/dist/useIsClient.mjs 233 B

compressed-size-action

Copy link
Copy Markdown

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

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

Comments suppressed due to low confidence (1)

packages/react/src/lazy.spec.tsx:6

  • Tests now import reloadOnErrorStorageKeySymbol and assert exact argument objects containing that symbol, which couples tests to an internal implementation detail and effectively requires the symbol to be part of the public API. Prefer asserting with partial matchers (e.g. expect.objectContaining({ error: expect.any(Error) })) and/or validating retry behavior via storage calls, so the symbol can remain private and refactors don't break tests unnecessarily.
import { createLazy, lazy, reloadOnError } from './lazy'

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

'use client'
import { type ComponentType, type LazyExoticComponent, lazy as originalLazy } from 'react'

const reloadOnErrorStorageKeySymbol = Symbol('reloadOnErrorStorageKey')
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

Exporting reloadOnErrorStorageKeySymbol appears to be only for plumbing/testing and leaks an internal implementation detail into the public API surface. Prefer keeping this symbol internal (non-exported) and adjust tests to assert via behavior (storage interactions) or use partial matchers (e.g., objectContaining) rather than depending on a public symbol.

Copilot uses AI. Check for mistakes.
Comment on lines +210 to +215
const storageKey = arg?.[reloadOnErrorStorageKeySymbol] as string
reloadStorage.removeItem(storageKey)
}) as LazyOptions['onSuccess'],
onError: (errorOptions: { error: unknown }) => {
options.onError?.(errorOptions)
const storageKey = (errorOptions as any)[reloadOnErrorStorageKeySymbol] as string
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

reloadOnError's onSuccess reads the storage key from arg?.[reloadOnErrorStorageKeySymbol] and then calls removeItem(storageKey). If the callback is ever invoked without that symbol property, this will pass undefined (coerced to 'undefined') and remove the wrong entry. Add a guard (return early / throw) when storageKey is missing, or compute a safe fallback key.

Suggested change
const storageKey = arg?.[reloadOnErrorStorageKeySymbol] as string
reloadStorage.removeItem(storageKey)
}) as LazyOptions['onSuccess'],
onError: (errorOptions: { error: unknown }) => {
options.onError?.(errorOptions)
const storageKey = (errorOptions as any)[reloadOnErrorStorageKeySymbol] as string
const storageKey = arg?.[reloadOnErrorStorageKeySymbol]
if (typeof storageKey !== 'string') return
reloadStorage.removeItem(storageKey)
}) as LazyOptions['onSuccess'],
onError: (errorOptions: { error: unknown }) => {
options.onError?.(errorOptions)
const storageKey = (errorOptions as any)[reloadOnErrorStorageKeySymbol]
if (typeof storageKey !== 'string') return

Copilot uses AI. Check for mistakes.
Comment on lines +213 to 217
onError: (errorOptions: { error: unknown }) => {
options.onError?.(errorOptions)
const storageKey = (errorOptions as any)[reloadOnErrorStorageKeySymbol] as string
let currentRetryCount = 0

Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

reloadOnError's onError assumes errorOptions carries reloadOnErrorStorageKeySymbol; if it's missing, storageKey becomes undefined and getItem/setItem/removeItem will operate on the wrong key. Guard against a missing/invalid storageKey before using storage and consider making the key part of the typed onError context so this cannot happen silently.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants