Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/packages/toast/Notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,14 @@ Notification.newInstance = (properties, callback) => {

let called = false

function ref(instance: any) {
function ref(instance: Notification | null) {
if (called) {
return
}
called = true
callback({
component: instance,
id,
destroy() {
unmount(element)
element && element.parentNode && element.parentNode.removeChild(element)
Expand Down
106 changes: 106 additions & 0 deletions src/packages/toast/__test__/toast.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ const onClickToast = vi.fn((type, msg, options?) => {
}
})

const waitTimeout = (delay: number) => {
return new Promise((resolve) => {
setTimeout(resolve, delay)
})
}

test('event click-show-toast test', async () => {
const { getByTestId } = render(
<Cell
Expand Down Expand Up @@ -136,3 +142,103 @@ test('event show-loading-toast', async () => {
expect(document.querySelector('.nut-toast-text')?.innerHTML).toBe('loading')
})
})

test('manually close in strict mode', async () => {
const time = 2000
const content = 'strict mode loading'
const { getByTestId } = render(
<Cell
data-testid="emit-click"
onClick={() => {
onClickToast('loading', content)
onClickToast('loading', content)
}}
/>
)
await waitFor(() => {
fireEvent.click(getByTestId('emit-click'))
expect(onClickToast).toBeCalled()
expect(document.querySelectorAll('.nut-toast-text')?.length).toBe(2)
})

Toast.clear()

await waitTimeout(time)
expect(document.querySelector('.nut-toast-text')?.innerHTML).toBe(undefined)
})

test('no content', async () => {
const { getByTestId } = render(
<Cell
data-testid="emit-click"
onClick={() => {
Toast.show({})
}}
/>
)
await waitFor(() => {
fireEvent.click(getByTestId('emit-click'))
expect(document.querySelector('.nut-toast-text')?.innerHTML).toBe(undefined)
})
})

test('string option', async () => {
const content = 'string option'
const { getByTestId } = render(
<Cell
data-testid="emit-click"
onClick={() => {
Toast.show(content)
}}
/>
)
await waitFor(() => {
fireEvent.click(getByTestId('emit-click'))
expect(document.querySelector('.nut-toast-text')?.innerHTML).toBe(content)
})
})

test('global config', async () => {
const content = 'global config'
const contentClassName = 'content-demo'
Toast.config({ contentClassName })
const { getByTestId } = render(
<Cell
data-testid="emit-click"
onClick={() => {
onClickToast('text', content)
}}
/>
)
await waitFor(() => {
fireEvent.click(getByTestId('emit-click'))
expect(document.querySelector(`.${contentClassName}`)).toBeTruthy()
expect(document.querySelector('.nut-toast-text')?.innerHTML).toBe(content)
})
})

test('1s after close', async () => {
const content = '1'
let isCalledClose = false
const { getByTestId } = render(
<Cell
data-testid="emit-click"
onClick={() => {
onClickToast('text', content, {
duration: 1,
onClose: () => {
isCalledClose = true
},
})
}}
/>
)
await waitFor(() => {
fireEvent.click(getByTestId('emit-click'))
expect(document.querySelector('.nut-toast-text')?.innerHTML).toBe(content)
})

await waitTimeout(2000)
expect(document.querySelector('.nut-toast-text')).toBe(null)
expect(isCalledClose).toBeTruthy()
})
31 changes: 25 additions & 6 deletions src/packages/toast/toast.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { ReactNode } from 'react'
import Notification from './Notification'
import { WebToastProps } from '@/types'
import { defaultOverlayProps } from '@/packages/overlay/overlay'
import { clone } from '@/utils'

let messageInstance: any = null
type NotificationInstance = {
component: Notification
id: string
destroy: () => void
}

let messageInstance: NotificationInstance | null = null
const messageInstanceSet = new Set<NotificationInstance>()

let defaultProps: WebToastProps = {
...defaultOverlayProps,
Expand All @@ -26,18 +35,18 @@ type ToastNativeProps = Partial<WebToastProps>

function getInstance(
props: ToastNativeProps,
callback: (notification: any) => void
callback: (notification: NotificationInstance) => void
) {
if (messageInstance) {
messageInstance.destroy()
messageInstance = null
}
Notification.newInstance(props, (notification: any) => {
Notification.newInstance(props, (notification: NotificationInstance) => {
return callback && callback(notification)
})
}

function notice(opts: any) {
function notice(opts: ToastNativeProps) {
function close() {
if (messageInstance) {
messageInstance.destroy()
Expand All @@ -46,12 +55,16 @@ function notice(opts: any) {
}
}
const opts2 = { ...defaultProps, ...opts, onClose: close }
getInstance(opts2, (notification: any) => {
getInstance(opts2, (notification: NotificationInstance) => {
const oldInstance = messageInstance ? clone(messageInstance) : null
if (notification.id === oldInstance?.id) {
messageInstanceSet.add(oldInstance)
}
messageInstance = notification
Comment on lines +58 to 63
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the toast.tsx file structure
fd -t f "toast.tsx" | head -5

Repository: jdf2e/nutui-react

Length of output: 89


🏁 Script executed:

# List files in the toast package to understand structure
find . -path "*/toast/*" -type f -name "*.tsx" -o -name "*.ts" | grep -v node_modules | head -20

Repository: jdf2e/nutui-react

Length of output: 891


🏁 Script executed:

# Check if the file path exists
ls -la src/packages/toast/toast.tsx 2>/dev/null || find . -name "toast.tsx" -type f | grep -v node_modules | head -5

Repository: jdf2e/nutui-react

Length of output: 138


🏁 Script executed:

# Read the toast.tsx file to understand the full context
cat -n src/packages/toast/toast.tsx

Repository: jdf2e/nutui-react

Length of output: 3281


🏁 Script executed:

# Find Notification.tsx file
fd -t f "Notification.tsx" | grep -v node_modules | head -5

Repository: jdf2e/nutui-react

Length of output: 133


🏁 Script executed:

# Search for ID generation logic using Date.now()
rg -n "Date\.now\(\)" src/packages/toast/ --type tsx --type ts

Repository: jdf2e/nutui-react

Length of output: 88


🏁 Script executed:

# Search for Toast.show usage patterns
rg -n "Toast\.show" --type tsx --type ts -A 2 -B 2 | head -50

Repository: jdf2e/nutui-react

Length of output: 88


🏁 Script executed:

# Read the Notification.tsx file to see ID generation logic
cat -n src/packages/toast/Notification.tsx

Repository: jdf2e/nutui-react

Length of output: 5558


🏁 Script executed:

# Search for Toast.show usage patterns without file type flag
rg -n "Toast\.show" -A 2 -B 2 | head -80

Repository: jdf2e/nutui-react

Length of output: 4039


🏁 Script executed:

# Search for Date.now() in the codebase
rg -n "Date\.now\(\)" -A 1 -B 1 | grep -E "(Notification|toast)" | head -20

Repository: jdf2e/nutui-react

Length of output: 493


🏁 Script executed:

# Search for ID generation and id assignment patterns
rg -n "id\s*[:=]" src/packages/toast/ -A 1 | head -40

Repository: jdf2e/nutui-react

Length of output: 2062


此实例跟踪逻辑存在无法到达的代码路径。

您的分析是正确的。当回调执行时,messageInstance 已在第 42 行被设置为 null,因此第 59 行的 oldInstance 始终为 null。这导致第 60 行的条件 notification.id === oldInstance?.id 始终为 false(比较 notification.id === undefined),使得 messageInstanceSet.add(oldInstance) 的代码路径无法到达。

即使用户显式提供相同的 ID,该条件也永远不会成立。clone() 调用同样是多余的,因为 oldInstance 总是 null

这段代码看起来意图是为了追踪多个实例,但由于逻辑流程的缺陷,实例集合的管理机制无法正常工作。建议审查实例追踪的设计意图,确定是否需要修改 getInstance 的逻辑(例如在销毁前保存旧实例)或移除无效的追踪代码。

🤖 Prompt for AI Agents
In src/packages/toast/toast.tsx around lines 58-63, the code computes
oldInstance from messageInstance but messageInstance is set to null earlier, so
oldInstance is always null and the id comparison / clone is unreachable; fix by
capturing the previous instance before it is nulled (store a local prev =
messageInstance at the top of the function or move the nulling after this
check), then compare notification.id to prev?.id and add prev (not null) to
messageInstanceSet if they match, or if the design does not require tracking
previous instances, remove the dead clone/conditional entirely; ensure the logic
consistently maintains messageInstance and messageInstanceSet according to the
intended lifecycle.

})
}

const errorMsg = (msg: any) => {
const errorMsg = (msg: ReactNode) => {
if (!msg) {
console.warn('[NutUI Toast]: msg cannot be null')
}
Expand Down Expand Up @@ -79,6 +92,12 @@ export default {
if (messageInstance) {
messageInstance.destroy()
messageInstance = null
if (messageInstanceSet?.size) {
messageInstanceSet.forEach((instance: NotificationInstance) => {
instance?.destroy()
})
messageInstanceSet.clear()
}
}
},
}
Loading