From d6ab36ff1ea263bc1249520b31e379f5e7985d10 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Tue, 3 Mar 2026 11:21:04 +0800 Subject: [PATCH 001/172] chore: update vinext, add workaround (#32878) --- .../components/devtools/react-scan/loader.tsx | 20 +-- .../components/devtools/react-scan/scan.tsx | 22 --- .../plugins/marketplace/hydration-server.tsx | 4 + web/app/layout.tsx | 3 +- web/app/sw.ts | 1 - web/package.json | 4 +- web/pnpm-lock.yaml | 144 +++++++----------- 7 files changed, 74 insertions(+), 124 deletions(-) delete mode 100644 web/app/components/devtools/react-scan/scan.tsx diff --git a/web/app/components/devtools/react-scan/loader.tsx b/web/app/components/devtools/react-scan/loader.tsx index ee702216f7..a5956d7825 100644 --- a/web/app/components/devtools/react-scan/loader.tsx +++ b/web/app/components/devtools/react-scan/loader.tsx @@ -1,21 +1,15 @@ -'use client' - -import { lazy, Suspense } from 'react' +import Script from 'next/script' import { IS_DEV } from '@/config' -const ReactScan = lazy(() => - import('./scan').then(module => ({ - default: module.ReactScan, - })), -) - -export const ReactScanLoader = () => { +export function ReactScanLoader() { if (!IS_DEV) return null return ( - - - + ') - }) - - it('renders empty script tag when child value is undefined', () => { - const node: ScriptNode = { - children: [{}], - } - - const { container } = render( - , - ) - - expect(container.textContent).toBe('') - }) - - it('renders empty script tag when children array is empty', () => { - const node: ScriptNode = { - children: [], - } - - const { container } = render( - , - ) - - expect(container.textContent).toBe('') - }) - - it('preserves multiline script content', () => { - const multi = `console.log("line1"); -console.log("line2");` - - const node: ScriptNode = { - children: [{ value: multi }], - } - - const { container } = render( - , - ) - - expect(container.textContent).toBe(``) - }) - - it('has displayName set correctly', () => { - expect(ScriptBlock.displayName).toBe('ScriptBlock') - }) -}) diff --git a/web/app/components/base/markdown-blocks/code-block.tsx b/web/app/components/base/markdown-blocks/code-block.tsx index 744a578ff6..837929cfff 100644 --- a/web/app/components/base/markdown-blocks/code-block.tsx +++ b/web/app/components/base/markdown-blocks/code-block.tsx @@ -399,7 +399,6 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any }} language={match?.[1]} showLineNumbers - PreTag="div" > {content} @@ -413,7 +412,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any return (
-
{languageShowName}
+
{languageShowName}
{language === 'svg' && } diff --git a/web/app/components/base/markdown-blocks/form.tsx b/web/app/components/base/markdown-blocks/form.tsx index bce05bc585..36597cd13c 100644 --- a/web/app/components/base/markdown-blocks/form.tsx +++ b/web/app/components/base/markdown-blocks/form.tsx @@ -1,14 +1,16 @@ +import type { Dayjs } from 'dayjs' +import type { ButtonProps } from '@/app/components/base/button' import * as React from 'react' -import { useEffect, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import Button from '@/app/components/base/button' import { useChatContext } from '@/app/components/base/chat/chat/context' import Checkbox from '@/app/components/base/checkbox' import DatePicker from '@/app/components/base/date-and-time-picker/date-picker' import TimePicker from '@/app/components/base/date-and-time-picker/time-picker' -import { formatDateForOutput } from '@/app/components/base/date-and-time-picker/utils/dayjs' +import { formatDateForOutput, toDayjs } from '@/app/components/base/date-and-time-picker/utils/dayjs' import Input from '@/app/components/base/input' -import Select from '@/app/components/base/select' import Textarea from '@/app/components/base/textarea' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select' enum DATA_FORMAT { TEXT = 'text', @@ -32,238 +34,359 @@ enum SUPPORTED_TYPES { SELECT = 'select', HIDDEN = 'hidden', } -const MarkdownForm = ({ node }: any) => { - const { onSend } = useChatContext() - const [formValues, setFormValues] = useState<{ [key: string]: any }>({}) +const SUPPORTED_TYPES_SET = new Set(Object.values(SUPPORTED_TYPES)) - useEffect(() => { - const initialValues: { [key: string]: any } = {} - node.children.forEach((child: any) => { - if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) { - initialValues[child.properties.name] - = (child.tagName === SUPPORTED_TAGS.INPUT && child.properties.type === SUPPORTED_TYPES.HIDDEN) - ? (child.properties.value || '') - : child.properties.value - } - }) - setFormValues(initialValues) - }, [node.children]) +const SAFE_NAME_RE = /^[a-z][\w-]*$/i +const PROTOTYPE_POISON_KEYS = new Set(['__proto__', 'constructor', 'prototype']) - const getFormValues = (children: any) => { - const values: { [key: string]: any } = {} - children.forEach((child: any) => { - if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) { - let value = formValues[child.properties.name] +function isSafeName(name: unknown): name is string { + return typeof name === 'string' + && name.length > 0 + && name.length <= 128 + && SAFE_NAME_RE.test(name) + && !PROTOTYPE_POISON_KEYS.has(name) +} - if (child.tagName === SUPPORTED_TAGS.INPUT - && (child.properties.type === SUPPORTED_TYPES.DATE || child.properties.type === SUPPORTED_TYPES.DATETIME)) { - if (value && typeof value.format === 'function') { - // Format date output consistently - const includeTime = child.properties.type === SUPPORTED_TYPES.DATETIME - value = formatDateForOutput(value, includeTime) - } - } +const VALID_BUTTON_VARIANTS = new Set([ + 'primary', + 'warning', + 'secondary', + 'secondary-accent', + 'ghost', + 'ghost-accent', + 'tertiary', +]) +const VALID_BUTTON_SIZES = new Set(['small', 'medium', 'large']) - values[child.properties.name] = value - } - }) - return values - } +type HastText = { + type: 'text' + value: string +} - const onSubmit = (e: any) => { - e.preventDefault() - const format = node.properties.dataFormat || DATA_FORMAT.TEXT - const result = getFormValues(node.children) +type HastElement = { + type: 'element' + tagName: string + properties: Record + children: Array +} - if (format === DATA_FORMAT.JSON) { - onSend?.(JSON.stringify(result)) +type FormValue = string | boolean | Dayjs | undefined +type FormValues = Record +type EditState = { + source: HastElement[] + edits: FormValues +} + +function getTextContent(node: HastElement): string { + const textChild = node.children.find((c): c is HastText => c.type === 'text') + return textChild?.value ?? '' +} + +function str(val: unknown): string { + if (val == null) + return '' + return String(val) +} + +function computeInitialFormValues(children: HastElement[]): FormValues { + const init: FormValues = Object.create(null) as FormValues + for (const child of children) { + if (child.tagName !== SUPPORTED_TAGS.INPUT && child.tagName !== SUPPORTED_TAGS.TEXTAREA) + continue + const name = child.properties.name + if (!isSafeName(name)) + continue + + const type = child.tagName === SUPPORTED_TAGS.INPUT ? str(child.properties.type) : '' + + if (type === SUPPORTED_TYPES.HIDDEN) { + init[name] = str(child.properties.value) + } + else if (type === SUPPORTED_TYPES.DATE || type === SUPPORTED_TYPES.DATETIME || type === SUPPORTED_TYPES.TIME) { + const raw = child.properties.value + init[name] = raw != null ? toDayjs(String(raw)) : undefined + } + else if (type === SUPPORTED_TYPES.CHECKBOX) { + const { checked, value } = child.properties + init[name] = !!checked || value === true || value === 'true' } else { - const textResult = Object.entries(result) - .map(([key, value]) => `${key}: ${value}`) - .join('\n') - onSend?.(textResult) + init[name] = child.properties.value != null ? str(child.properties.value) : undefined } } + return init +} + +function getElementKey(child: HastElement, index: number): string { + const tag = child.tagName + const name = str(child.properties.name) + const htmlFor = str(child.properties.htmlFor) + const type = str(child.properties.type) + + if (tag === SUPPORTED_TAGS.LABEL) + return `label-${index}-${htmlFor || name}` + if (tag === SUPPORTED_TAGS.INPUT) + return `input-${index}-${type}-${name}` + if (tag === SUPPORTED_TAGS.TEXTAREA) + return `textarea-${index}-${name}` + if (tag === SUPPORTED_TAGS.BUTTON) + return `button-${index}-${getTextContent(child)}` + return `${tag}-${index}` +} + +const MarkdownForm = ({ node }: { node: HastElement }) => { + const typedNode = node + const { onSend } = useChatContext() + const [isSubmitting, setIsSubmitting] = useState(false) + + const elementChildren = useMemo( + () => typedNode.children.filter((c): c is HastElement => c.type === 'element'), + [typedNode.children], + ) + + const baseFormValues = useMemo( + () => computeInitialFormValues(elementChildren), + [elementChildren], + ) + + const [editState, setEditState] = useState(() => ({ + source: elementChildren, + edits: {}, + })) + + const formValues = useMemo(() => { + if (editState.source === elementChildren) + return { ...baseFormValues, ...editState.edits } + return baseFormValues + }, [editState, baseFormValues, elementChildren]) + + const updateValue = useCallback((name: string, value: FormValue) => { + if (!isSafeName(name)) + return + setEditState(prev => ({ + source: elementChildren, + edits: { + ...(prev.source === elementChildren ? prev.edits : {}), + [name]: value, + }, + })) + }, [elementChildren]) + + const getFormOutput = useCallback((): Record => { + const out = Object.create(null) as Record + for (const child of elementChildren) { + if (child.tagName !== SUPPORTED_TAGS.INPUT && child.tagName !== SUPPORTED_TAGS.TEXTAREA) + continue + const name = child.properties.name + if (!isSafeName(name)) + continue + let value: FormValue = formValues[name] + if ( + child.tagName === SUPPORTED_TAGS.INPUT + && (child.properties.type === SUPPORTED_TYPES.DATE || child.properties.type === SUPPORTED_TYPES.DATETIME) + && value != null + && typeof value === 'object' + && 'format' in value + ) { + const includeTime = child.properties.type === SUPPORTED_TYPES.DATETIME + value = formatDateForOutput(value as Dayjs, includeTime) + } + if (typeof value === 'boolean') + out[name] = value + else + out[name] = value != null ? String(value) : undefined + } + return out + }, [elementChildren, formValues]) + + const onSubmit = useCallback((e: React.MouseEvent) => { + e.preventDefault() + if (isSubmitting) + return + setIsSubmitting(true) + try { + const format = str(typedNode.properties.dataFormat) || DATA_FORMAT.TEXT + const result = getFormOutput() + if (format === DATA_FORMAT.JSON) { + onSend?.(JSON.stringify(result)) + } + else { + const textResult = Object.entries(result) + .map(([key, value]) => `${key}: ${value}`) + .join('\n') + onSend?.(textResult) + } + } + catch { + setIsSubmitting(false) + } + }, [isSubmitting, typedNode.properties.dataFormat, getFormOutput, onSend]) + return (
{ + onSubmit={(e) => { e.preventDefault() e.stopPropagation() }} > - {node.children.filter((i: any) => i.type === 'element').map((child: any, index: number) => { + {elementChildren.map((child, index) => { + const key = getElementKey(child, index) if (child.tagName === SUPPORTED_TAGS.LABEL) { return ( ) } - if (child.tagName === SUPPORTED_TAGS.INPUT && Object.values(SUPPORTED_TYPES).includes(child.properties.type)) { - if (child.properties.type === SUPPORTED_TYPES.DATE || child.properties.type === SUPPORTED_TYPES.DATETIME) { + + if (child.tagName === SUPPORTED_TAGS.INPUT && SUPPORTED_TYPES_SET.has(str(child.properties.type))) { + const name = str(child.properties.name) + if (!isSafeName(name)) + return null + + const type = str(child.properties.type) as SUPPORTED_TYPES + + if (type === SUPPORTED_TYPES.DATE || type === SUPPORTED_TYPES.DATETIME) { return ( { - setFormValues(prevValues => ({ - ...prevValues, - [child.properties.name]: date, - })) - }} - onClear={() => { - setFormValues(prevValues => ({ - ...prevValues, - [child.properties.name]: undefined, - })) - }} + key={key} + value={formValues[name] as Dayjs | undefined} + needTimePicker={type === SUPPORTED_TYPES.DATETIME} + onChange={date => updateValue(name, date)} + onClear={() => updateValue(name, undefined)} /> ) } - if (child.properties.type === SUPPORTED_TYPES.TIME) { + if (type === SUPPORTED_TYPES.TIME) { return ( { - setFormValues(prevValues => ({ - ...prevValues, - [child.properties.name]: time, - })) - }} - onClear={() => { - setFormValues(prevValues => ({ - ...prevValues, - [child.properties.name]: undefined, - })) - }} + key={key} + value={formValues[name] as Dayjs | string | undefined} + onChange={time => updateValue(name, time)} + onClear={() => updateValue(name, undefined)} /> ) } - if (child.properties.type === SUPPORTED_TYPES.CHECKBOX) { + if (type === SUPPORTED_TYPES.CHECKBOX) { return ( -
+
{ - setFormValues(prevValues => ({ - ...prevValues, - [child.properties.name]: !prevValues[child.properties.name], - })) - }} - id={child.properties.name} + checked={!!formValues[name]} + onCheck={() => updateValue(name, !formValues[name])} + id={name} /> - {child.properties.dataTip || child.properties['data-tip'] || ''} + {str(child.properties.dataTip || child.properties['data-tip'])}
) } - if (child.properties.type === SUPPORTED_TYPES.SELECT) { + if (type === SUPPORTED_TYPES.SELECT) { + const rawOptions = child.properties.dataOptions || child.properties['data-options'] || [] + let options: string[] = [] + if (typeof rawOptions === 'string') { + try { + const parsed: unknown = JSON.parse(rawOptions) + if (Array.isArray(parsed)) + options = parsed.filter((o): o is string => typeof o === 'string') + } + catch (error) { + console.error('Failed to parse data-options JSON:', rawOptions, error) + options = [] + } + } + else if (Array.isArray(rawOptions)) { + options = rawOptions.filter((o): o is string => typeof o === 'string') + } return ( ) } - if (child.properties.type === SUPPORTED_TYPES.HIDDEN) { + if (type === SUPPORTED_TYPES.HIDDEN) { return ( ) } return ( { - setFormValues(prevValues => ({ - ...prevValues, - [child.properties.name]: e.target.value, - })) - }} + key={key} + type={type} + name={name} + placeholder={str(child.properties.placeholder)} + value={str(formValues[name])} + onChange={e => updateValue(name, e.target.value)} /> ) } + if (child.tagName === SUPPORTED_TAGS.TEXTAREA) { + const name = str(child.properties.name) + if (!isSafeName(name)) + return null return (