From 7b2068cb3559330fce94cbe493f9e15a10a90344 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 2 Aug 2026 14:17:14 +0200 Subject: [PATCH 01/12] feat(react): harden UI and component surfaces --- scripts/lint/ban-chat-antipatterns.ts | 8 +- scripts/lint/module-boundaries-baseline.json | 5 - scripts/lint/test-typecheck-baseline.json | 8 +- src/react/README.md | 7 +- src/react/chat-barrels.check.ts | 141 + src/react/compat/config-generator.ts | 76 +- src/react/compat/hooks-adapter.test.ts | 5 +- src/react/compat/ssr-adapter/html-wrapper.ts | 21 +- .../ssr-adapter/response-builder.test.ts | 82 + .../compat/ssr-adapter/response-builder.ts | 161 +- .../version-detector/compatibility-checker.ts | 29 +- .../version-detector/feature-detector.test.ts | 154 +- .../version-detector/feature-detector.ts | 155 +- src/react/compat/version-detector/types.ts | 46 +- .../compat/version-detector/version-cache.ts | 11 +- .../version-detector/version-detector.test.ts | 99 +- .../version-detector/version-parser.test.ts | 54 +- .../compat/version-detector/version-parser.ts | 80 +- src/react/components/AppWrapper.test.tsx | 57 + src/react/components/AppWrapper.tsx | 4 +- src/react/components/LayoutComponent.tsx | 54 +- src/react/components/ProviderComponent.tsx | 52 +- .../components/chat/agent-picker.test.tsx | 29 + src/react/components/chat/agent-picker.tsx | 28 +- .../components/chat/chat-actions-settings.tsx | 181 ++ src/react/components/chat/chat-actions.tsx | 196 +- src/react/components/chat/chat.test.tsx | 2 + .../chat/chat/chat.characterization.test.tsx | 2 + .../components/chat/chat/chat.index.test.tsx | 2 + .../chat/components/attachment-pill.test.tsx | 7 + .../chat/chat/components/attachment-pill.tsx | 2 +- .../chat/chat/components/code-block.test.tsx | 132 +- .../chat/chat/components/code-block.tsx | 51 +- .../chat/components/message-actions.test.tsx | 60 +- .../chat/chat/components/message-actions.tsx | 43 +- .../chat/chat/components/sidebar.test.tsx | 8 + .../chat/chat/components/sidebar.tsx | 3 +- .../chat/chat/components/tool-ui.test.tsx | 52 + .../chat/chat/components/tool-ui.tsx | 30 +- .../chat/chat/composition/message-body.tsx | 4 +- .../chat/chat/composition/message.test.tsx | 68 + .../chat/chat/composition/message.tsx | 32 +- .../chat/chat/contexts/message-context.tsx | 4 +- .../chat/chat/controlled-chat.test.tsx | 2 + .../chat/chat/hooks/use-clipboard.ts | 46 +- src/react/components/chat/error-boundary.tsx | 2 - src/react/components/chat/index.ts | 41 + src/react/components/clipboard.test.tsx | 303 ++ src/react/components/clipboard.ts | 233 ++ src/react/components/index.ts | 7 +- src/react/components/mdx-wrapper-error.ts | 32 + .../OptimizedBackgroundImage.tsx | 22 +- .../optimized-image/OptimizedImage.tsx | 40 +- .../optimized-image/SimpleOptimizedImage.tsx | 18 +- .../optimized-image/helpers.test.ts | 140 +- .../components/optimized-image/helpers.ts | 120 +- src/react/components/optimized-image/index.ts | 2 + .../optimized-image/useOptimizedImage.ts | 30 +- .../components/optimized-image/utils.test.ts | 66 + .../components/optimized-image/utils.tsx | 75 +- src/react/components/ui/anchored-surface.tsx | 122 +- src/react/components/ui/code-block.test.tsx | 374 +++ src/react/components/ui/code-block.tsx | 510 ++-- src/react/components/ui/collapsible.tsx | 66 +- src/react/components/ui/command.test.tsx | 449 +++ src/react/components/ui/command.tsx | 444 ++- src/react/components/ui/dialog.tsx | 57 +- src/react/components/ui/drawer.tsx | 43 +- src/react/components/ui/dropdown-menu.tsx | 138 +- src/react/components/ui/floating.test.tsx | 350 +++ src/react/components/ui/floating.tsx | 133 +- src/react/components/ui/focus-management.ts | 95 + src/react/components/ui/icon-button.tsx | 3 +- src/react/components/ui/index.test.ts | 1 + src/react/components/ui/index.ts | 11 +- src/react/components/ui/input.tsx | 6 +- src/react/components/ui/list.test.tsx | 552 ++++ src/react/components/ui/list.tsx | 218 +- src/react/components/ui/modal-surface.tsx | 248 +- .../components/ui/overlay-surfaces.test.tsx | 338 +++ src/react/components/ui/popover.tsx | 19 +- src/react/components/ui/select.test.tsx | 1246 ++++++++ src/react/components/ui/select.tsx | 1276 +++++++- src/react/components/ui/slot.test.tsx | 142 + src/react/components/ui/slot.tsx | 118 +- src/react/components/ui/textarea.tsx | 5 +- src/react/components/ui/tooltip.test.tsx | 767 +++++ src/react/components/ui/tooltip.tsx | 920 +++++- src/react/context/image-manifest.test.tsx | 78 + src/react/context/index.tsx | 14 +- src/react/fonts/index.test.tsx | 129 + src/react/fonts/index.ts | 209 +- src/react/index.ts | 10 +- src/react/primitives/README.md | 12 +- src/react/primitives/input-box.test.tsx | 343 +++ src/react/primitives/input-box.tsx | 77 +- src/react/primitives/tool-primitives.test.tsx | 88 + src/react/primitives/tool-primitives.tsx | 27 +- src/react/public.ts | 67 +- src/react/runtime/core.ts | 118 + .../dev/framework-candidates.generated.ts | 2617 +++++++++++++---- src/types/index.ts | 40 + src/utils/optimized-image-manifest.ts | 414 +++ 103 files changed, 14279 insertions(+), 2039 deletions(-) create mode 100644 src/react/compat/ssr-adapter/response-builder.test.ts create mode 100644 src/react/components/AppWrapper.test.tsx create mode 100644 src/react/components/chat/chat-actions-settings.tsx create mode 100644 src/react/components/clipboard.test.tsx create mode 100644 src/react/components/clipboard.ts create mode 100644 src/react/components/mdx-wrapper-error.ts create mode 100644 src/react/components/optimized-image/utils.test.ts create mode 100644 src/react/components/ui/code-block.test.tsx create mode 100644 src/react/components/ui/command.test.tsx create mode 100644 src/react/components/ui/floating.test.tsx create mode 100644 src/react/components/ui/focus-management.ts create mode 100644 src/react/components/ui/list.test.tsx create mode 100644 src/react/components/ui/overlay-surfaces.test.tsx create mode 100644 src/react/components/ui/select.test.tsx create mode 100644 src/react/components/ui/slot.test.tsx create mode 100644 src/react/components/ui/tooltip.test.tsx create mode 100644 src/react/context/image-manifest.test.tsx create mode 100644 src/react/fonts/index.test.tsx create mode 100644 src/react/primitives/input-box.test.tsx create mode 100644 src/react/primitives/tool-primitives.test.tsx create mode 100644 src/utils/optimized-image-manifest.ts diff --git a/scripts/lint/ban-chat-antipatterns.ts b/scripts/lint/ban-chat-antipatterns.ts index e2801e786d..a786332680 100644 --- a/scripts/lint/ban-chat-antipatterns.ts +++ b/scripts/lint/ban-chat-antipatterns.ts @@ -98,14 +98,14 @@ const FILE_SIZE_CEILINGS: Record = { // only for the explicit conversation-persistence contracts exported here. "src/react/components/chat/chat/index.tsx": 280, // Message.Sources extracted to composition/message-sources.tsx. - "src/react/components/chat/chat/composition/message.tsx": 987, + "src/react/components/chat/chat/composition/message.tsx": 906, // Includes the ChatSidebar.Item menu compound (Item.Menu/.Rename/.Delete). // Split responsibilities before adding more behavior to this file. - "src/react/components/chat/chat/components/sidebar.tsx": 736, + "src/react/components/chat/chat/components/sidebar.tsx": 721, // useComposerValue extracted to composition/use-composer-value.ts. "src/react/components/chat/chat/composition/chat-composer.tsx": 619, - "src/react/components/chat/agent-picker.tsx": 502, - "src/react/components/chat/chat-actions.tsx": 515, + "src/react/components/chat/agent-picker.tsx": 494, + "src/react/components/chat/chat-actions.tsx": 315, "src/react/components/chat/chat/controlled-chat.tsx": 242, "src/react/components/chat/chat/app-mode-chat.tsx": 177, }; diff --git a/scripts/lint/module-boundaries-baseline.json b/scripts/lint/module-boundaries-baseline.json index d7e62b74ff..9fd53a5ff2 100644 --- a/scripts/lint/module-boundaries-baseline.json +++ b/scripts/lint/module-boundaries-baseline.json @@ -1,10 +1,5 @@ { "broadBarrelImports": [ - "browser:src/react/components/LayoutComponent.tsx -> #veryfront/utils", - "browser:src/react/components/ProviderComponent.tsx -> #veryfront/utils", - "browser:src/react/components/optimized-image/OptimizedImage.tsx -> #veryfront/utils", - "browser:src/react/components/optimized-image/SimpleOptimizedImage.tsx -> #veryfront/utils", - "browser:src/react/components/optimized-image/useOptimizedImage.ts -> #veryfront/utils", "browser:src/rendering/client/prefetch.ts -> #veryfront/utils", "browser:src/rendering/client/router.ts -> #veryfront/utils", "browser:src/rendering/client/state-bridge.ts -> #veryfront/utils", diff --git a/scripts/lint/test-typecheck-baseline.json b/scripts/lint/test-typecheck-baseline.json index 4f387e9639..5fd4f115b0 100644 --- a/scripts/lint/test-typecheck-baseline.json +++ b/scripts/lint/test-typecheck-baseline.json @@ -40,11 +40,9 @@ "src/platform/adapters/fs/veryfront/adapter-helpers.test.ts", "src/platform/adapters/fs/veryfront/directory-operations.test.ts", "src/platform/adapters/redis/node.test.ts", - "src/react/components/chat/chat.test.tsx", - "src/react/components/chat/chat/chat.characterization.test.tsx", - "src/react/components/chat/chat/chat.index.test.tsx", - "src/react/components/chat/chat/controlled-chat.test.tsx", - "src/react/components/optimized-image/helpers.test.ts", + "src/platform/adapters/runtime/shared/watcher-queue.test.ts", + "src/platform/adapters/token/factory.test.ts", + "src/prompt/factory.test.ts", "src/rendering/chunk-optimizer.test.ts", "src/rendering/client/browser-logger.test.ts", "src/rendering/element-validator/validator-core.test.ts", diff --git a/src/react/README.md b/src/react/README.md index 270ae6972b..1da1a99695 100644 --- a/src/react/README.md +++ b/src/react/README.md @@ -52,7 +52,7 @@ react/ │ ├── chat.tsx # Complete chat interface │ ├── agent-card.tsx # Agent status visualization │ ├── message.tsx # Standalone message component - │ ├── markdown.tsx # Markdown rendering component + │ ├── markdown.tsx # Plain source + rich-renderer capability boundary │ ├── model-selector.tsx # Model selection UI │ ├── chat-with-sidebar.tsx # Chat with sidebar layout │ ├── theme.ts # Theme system and defaults @@ -84,7 +84,7 @@ react/ - `Chat` - Complete chat interface with theme system and composition API - `AgentCard` - Agent status and tool visualization - `Message` - Standalone message component (parts-based format) -- `Markdown` - Markdown rendering with syntax highlighting +- `Markdown` - Escaped Markdown source, with semantic rendering supplied by an extension - `ModelSelector` - Model selection UI component - `ChatSidebar` - Conversation rail (pairs with `ConversationsProvider`) - `OptimizedImage` - Responsive image with lazy loading @@ -309,7 +309,8 @@ const response = buildResponse(html, { - All React 18 features - RSC (React Server Components) -- Server Actions +- Server Actions with a required generation-owned authorization provider; see + [Authorize React Server Actions](../../docs/guides/extensions.md#authorize-react-server-actions) - Improved Suspense - Enhanced streaming diff --git a/src/react/chat-barrels.check.ts b/src/react/chat-barrels.check.ts index 824f1b7938..5345487a6b 100644 --- a/src/react/chat-barrels.check.ts +++ b/src/react/chat-barrels.check.ts @@ -183,3 +183,144 @@ export type ChatReactBarrelContracts = [ ChatUseConversationsPersistenceState, ChatUseConversationsResult, ]; + +type CompoundChatRuntimeExport = + | "AgentAvatar" + | "ChatEmptyState" + | "ChatMessagesSkeleton" + | "SkillBadge" + | "SourcePill" + | "isSkillToolPart" + | "useAttachmentPill" + | "useAttachments" + | "useAttachmentsPanel" + | "useModelSelector" + | "useSources" + | "useStepIndicator" + | "useStickToBottom" + | "useUpload" + | "useUploadsRegistry"; + +type ContainsRuntimeExports = + Exclude extends never ? true : never; + +const compoundRuntimeParity: [ + ContainsRuntimeExports, + ContainsRuntimeExports< + typeof import("./components/chat/index.ts"), + CompoundChatRuntimeExport + >, +] = [true, true]; + +void compoundRuntimeParity; + +export type CompoundChatPublicTypeContracts = [ + import("./public.ts").AgentAvatarProps, + import("./public.ts").AttachmentPillContextValue, + import("./public.ts").AttachmentsPanelActionProps, + import("./public.ts").AttachmentsPanelContextValue, + import("./public.ts").AttachmentsPanelEmptyProps, + import("./public.ts").AttachmentsPanelHeaderProps, + import("./public.ts").AttachmentsPanelItemProps, + import("./public.ts").AttachmentsPanelListProps, + import("./public.ts").AttachmentsPanelLoadingProps, + import("./public.ts").ChatEmptyStateAvatarProps, + import("./public.ts").ChatEmptyStateHeadingProps, + import("./public.ts").ChatEmptyStateRootProps, + import("./public.ts").ChatEmptyStateSuggestionProps, + import("./public.ts").ChatEmptyStateSuggestionsProps, + import("./public.ts").ChatMessagesSkeletonProps, + import("./public.ts").ModelSelectorContentProps, + import("./public.ts").ModelSelectorContextValue, + import("./public.ts").ModelSelectorItemProps, + import("./public.ts").ModelSelectorTriggerProps, + import("./public.ts").SkillBadgeProps, + import("./public.ts").SourcePillProps, + import("./public.ts").SourcesContextValue, + import("./public.ts").SourcesListProps, + import("./public.ts").StepIndicatorContextValue, + import("./public.ts").UseAttachmentsOptions, + import("./public.ts").UseAttachmentsRequestState, + import("./public.ts").UseAttachmentsResult, + import("./public.ts").UseAttachmentsStorageState, + import("./public.ts").UseStickToBottomOptions, + import("./public.ts").UseStickToBottomResult, + import("./public.ts").UseUploadOptions, + import("./public.ts").UseUploadResult, + import("./public.ts").UseUploadsRegistryOptions, + import("./public.ts").UseUploadsRegistryResult, +]; + +export type CompoundChatComponentTypeContracts = [ + import("./components/chat/index.ts").AgentAvatarProps, + import("./components/chat/index.ts").AttachmentPillContextValue, + import("./components/chat/index.ts").AttachmentsPanelActionProps, + import("./components/chat/index.ts").AttachmentsPanelContextValue, + import("./components/chat/index.ts").AttachmentsPanelEmptyProps, + import("./components/chat/index.ts").AttachmentsPanelHeaderProps, + import("./components/chat/index.ts").AttachmentsPanelItemProps, + import("./components/chat/index.ts").AttachmentsPanelListProps, + import("./components/chat/index.ts").AttachmentsPanelLoadingProps, + import("./components/chat/index.ts").ChatEmptyStateAvatarProps, + import("./components/chat/index.ts").ChatEmptyStateHeadingProps, + import("./components/chat/index.ts").ChatEmptyStateRootProps, + import("./components/chat/index.ts").ChatEmptyStateSuggestionProps, + import("./components/chat/index.ts").ChatEmptyStateSuggestionsProps, + import("./components/chat/index.ts").ChatMessagesSkeletonProps, + import("./components/chat/index.ts").ModelSelectorContentProps, + import("./components/chat/index.ts").ModelSelectorContextValue, + import("./components/chat/index.ts").ModelSelectorItemProps, + import("./components/chat/index.ts").ModelSelectorTriggerProps, + import("./components/chat/index.ts").SkillBadgeProps, + import("./components/chat/index.ts").SourcePillProps, + import("./components/chat/index.ts").SourcesContextValue, + import("./components/chat/index.ts").SourcesListProps, + import("./components/chat/index.ts").StepIndicatorContextValue, + import("./components/chat/index.ts").UseAttachmentsOptions, + import("./components/chat/index.ts").UseAttachmentsRequestState, + import("./components/chat/index.ts").UseAttachmentsResult, + import("./components/chat/index.ts").UseAttachmentsStorageState, + import("./components/chat/index.ts").UseStickToBottomOptions, + import("./components/chat/index.ts").UseStickToBottomResult, + import("./components/chat/index.ts").UseUploadOptions, + import("./components/chat/index.ts").UseUploadResult, + import("./components/chat/index.ts").UseUploadsRegistryOptions, + import("./components/chat/index.ts").UseUploadsRegistryResult, +]; + +export type CompoundCanonicalChatTypeContracts = [ + import("../chat/index.ts").AgentAvatarProps, + import("../chat/index.ts").AttachmentPillContextValue, + import("../chat/index.ts").AttachmentsPanelActionProps, + import("../chat/index.ts").AttachmentsPanelContextValue, + import("../chat/index.ts").AttachmentsPanelEmptyProps, + import("../chat/index.ts").AttachmentsPanelHeaderProps, + import("../chat/index.ts").AttachmentsPanelItemProps, + import("../chat/index.ts").AttachmentsPanelListProps, + import("../chat/index.ts").AttachmentsPanelLoadingProps, + import("../chat/index.ts").ChatEmptyStateAvatarProps, + import("../chat/index.ts").ChatEmptyStateHeadingProps, + import("../chat/index.ts").ChatEmptyStateRootProps, + import("../chat/index.ts").ChatEmptyStateSuggestionProps, + import("../chat/index.ts").ChatEmptyStateSuggestionsProps, + import("../chat/index.ts").ChatMessagesSkeletonProps, + import("../chat/index.ts").ModelSelectorContentProps, + import("../chat/index.ts").ModelSelectorContextValue, + import("../chat/index.ts").ModelSelectorItemProps, + import("../chat/index.ts").ModelSelectorTriggerProps, + import("../chat/index.ts").SkillBadgeProps, + import("../chat/index.ts").SourcePillProps, + import("../chat/index.ts").SourcesContextValue, + import("../chat/index.ts").SourcesListProps, + import("../chat/index.ts").StepIndicatorContextValue, + import("../chat/index.ts").UseAttachmentsOptions, + import("../chat/index.ts").UseAttachmentsRequestState, + import("../chat/index.ts").UseAttachmentsResult, + import("../chat/index.ts").UseAttachmentsStorageState, + import("../chat/index.ts").UseStickToBottomOptions, + import("../chat/index.ts").UseStickToBottomResult, + import("../chat/index.ts").UseUploadOptions, + import("../chat/index.ts").UseUploadResult, + import("../chat/index.ts").UseUploadsRegistryOptions, + import("../chat/index.ts").UseUploadsRegistryResult, +]; diff --git a/src/react/compat/config-generator.ts b/src/react/compat/config-generator.ts index 19c7a8de66..7e8d6668ec 100644 --- a/src/react/compat/config-generator.ts +++ b/src/react/compat/config-generator.ts @@ -1,12 +1,12 @@ import { rendererLogger as logger } from "#veryfront/utils"; import { join } from "#veryfront/compat/path/index.ts"; import { createError, toError } from "#veryfront/errors"; -import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { createFileSystem, isNotFoundError } from "#veryfront/platform/compat/fs.ts"; import { getReactUrls } from "#veryfront/transforms/esm/react-cdn.ts"; import { REACT_VERSION_17, REACT_VERSION_18_2, - REACT_VERSION_19_RC, + REACT_VERSION_19, } from "#veryfront/utils/constants/cdn.ts"; export type ReactVersion = "17" | "18" | "19"; @@ -36,11 +36,29 @@ export const REACT_CONFIGS: Record = { }, "19": { version: "19", - exact: REACT_VERSION_19_RC, - imports: getReactUrls(REACT_VERSION_19_RC), + exact: REACT_VERSION_19, + imports: getReactUrls(REACT_VERSION_19), }, }; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function getImportMap( + value: unknown, + source: string, +): Record { + if (value === undefined) return {}; + if ( + !isRecord(value) || + Object.values(value).some((specifier) => typeof specifier !== "string") + ) { + throw new TypeError(`${source} imports must be an object with string values`); + } + return value as Record; +} + function getReactConfig(version: ReactVersion): ReactVersionConfig { const config = REACT_CONFIGS[version]; if (config) return config; @@ -65,21 +83,28 @@ export async function generateReactVersionConfig( let baseConfig: Record = {}; try { - baseConfig = JSON.parse(await fs.readTextFile(baseConfigPath)); + const parsed: unknown = JSON.parse(await fs.readTextFile(baseConfigPath)); + if (!isRecord(parsed)) { + throw new TypeError(`Base config at ${baseConfigPath} must contain a JSON object`); + } + baseConfig = parsed; } catch (error) { - logger.warn(`Could not read base config from ${baseConfigPath}`, error); + if (!isNotFoundError(error)) throw error; + logger.warn(`Base config not found at ${baseConfigPath}; generating without it`); } - const baseImports = (baseConfig.imports as Record | undefined) ?? {}; - const additionalImports = (options.additional?.imports as Record | undefined) ?? - {}; + const baseImports = getImportMap(baseConfig.imports, `Base config at ${baseConfigPath}`); + const additionalImports = getImportMap( + options.additional?.imports, + "Additional config", + ); const versionConfig = { ...baseConfig, imports: { ...baseImports, - ...config.imports, ...additionalImports, + ...config.imports, }, }; @@ -108,27 +133,22 @@ export async function detectReactVersionFromConfig( const configPath = join(projectDir, "deno.json"); try { - const config = JSON.parse(await fs.readTextFile(configPath)) as { - imports?: { react?: string }; - }; - - const reactImport = config.imports?.react; - if (!reactImport) return null; - - for (const [version, versionConfig] of Object.entries(REACT_CONFIGS)) { - if (reactImport.includes(`@${versionConfig.exact}`)) { - return version as ReactVersion; - } + const parsed: unknown = JSON.parse(await fs.readTextFile(configPath)); + if (!isRecord(parsed)) { + throw new TypeError(`React config at ${configPath} must contain a JSON object`); } + const reactImport = getImportMap( + parsed.imports, + `React config at ${configPath}`, + ).react; + if (!reactImport) return null; - if (reactImport.includes("@17")) return "17"; - if (reactImport.includes("@18")) return "18"; - if (reactImport.includes("@19")) return "19"; - - return null; + const match = /(?:^|[/:])react@(\d+)(?=$|[.\-+/?#&:])/u.exec(reactImport); + const major = match?.[1]; + return major === "17" || major === "18" || major === "19" ? major : null; } catch (error) { - logger.error("Failed to detect React version from config", error); - return null; + if (isNotFoundError(error)) return null; + throw error; } } diff --git a/src/react/compat/hooks-adapter.test.ts b/src/react/compat/hooks-adapter.test.ts index c0aa1c8d7e..56dd5a8f9e 100644 --- a/src/react/compat/hooks-adapter.test.ts +++ b/src/react/compat/hooks-adapter.test.ts @@ -182,7 +182,10 @@ describe("hooks-adapter", () => { assertEquals(info.features.renderToString, true); assertEquals(info.features.renderToStaticMarkup, true); - assertEquals(info.features.renderToNodeStream, true); + assertEquals( + info.features.renderToNodeStream, + info.major < 19 && !info.isReact19, + ); if (info.major >= 18) { assertEquals(info.features.renderToPipeableStream, true); diff --git a/src/react/compat/ssr-adapter/html-wrapper.ts b/src/react/compat/ssr-adapter/html-wrapper.ts index 2e47ed6741..620197f3ec 100644 --- a/src/react/compat/ssr-adapter/html-wrapper.ts +++ b/src/react/compat/ssr-adapter/html-wrapper.ts @@ -1,6 +1,11 @@ import { buildAttributes, escapeHTML } from "#veryfront/html/html-escape.ts"; import type { HTMLWrapOptions } from "./types.ts"; +export interface HTMLShell { + prefix: string; + suffix: string; +} + function attributes(values: Readonly>): string { return buildAttributes( Object.fromEntries( @@ -9,7 +14,7 @@ function attributes(values: Readonly>): strin ); } -export function wrapInHTML(content: string, options: HTMLWrapOptions): string { +export function createHTMLShell(options: HTMLWrapOptions): HTMLShell { const metaTags = Object.entries(options.meta) .map(([name, metaContent]) => ``) .join("\n "); @@ -26,7 +31,8 @@ export function wrapInHTML(content: string, options: HTMLWrapOptions): string { .map((src) => ``) .join("\n "); - return ` + return { + prefix: ` @@ -37,8 +43,15 @@ export function wrapInHTML(content: string, options: HTMLWrapOptions): string { ${scriptTags} -
${content}
+
`, + suffix: `
${bootstrapScriptTags} -`; +`, + }; +} + +export function wrapInHTML(content: string, options: HTMLWrapOptions): string { + const { prefix, suffix } = createHTMLShell(options); + return `${prefix}${content}${suffix}`; } diff --git a/src/react/compat/ssr-adapter/response-builder.test.ts b/src/react/compat/ssr-adapter/response-builder.test.ts new file mode 100644 index 0000000000..291153eaad --- /dev/null +++ b/src/react/compat/ssr-adapter/response-builder.test.ts @@ -0,0 +1,82 @@ +import { assertEquals, assertRejects, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createSSRResponseFromResult } from "./response-builder.ts"; + +const encoder = new TextEncoder(); + +describe("createSSRResponse", () => { + it("wraps readable renderer output in a complete HTML document", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("
streamed
")); + controller.close(); + }, + }); + const response = await createSSRResponseFromResult( + { stream }, + { + title: "Stream & shell", + meta: { description: "stream response" }, + headers: new Headers({ "Content-Length": "7" }), + }, + "19.2.4", + ); + const html = await response.text(); + + assertStringIncludes(html, ""); + assertStringIncludes(html, "Stream & shell"); + assertStringIncludes(html, ''); + assertStringIncludes(html, '
streamed
'); + assertEquals(response.headers.get("content-type"), "text/html; charset=utf-8"); + assertEquals(response.headers.get("x-content-type-options"), "nosniff"); + assertEquals(response.headers.get("x-react-version"), "19.2.4"); + assertEquals(response.headers.get("content-length"), null); + }); + + it("bridges a Node pipeable result into the same document stream", async () => { + const response = await createSSRResponseFromResult( + { + pipe(destination) { + destination.write(encoder.encode("
pipeable
")); + destination.end(); + }, + }, + { title: "Pipeable" }, + "18.3.1", + ); + + assertStringIncludes( + await response.text(), + '
pipeable
', + ); + }); + + it("fails closed when a renderer violates the result contract", async () => { + await assertRejects( + () => createSSRResponseFromResult({}, {}, "19.2.4"), + TypeError, + "returned no HTML", + ); + }); + + it("aborts pipeable rendering when the response consumer cancels", async () => { + let abortCalls = 0; + const response = await createSSRResponseFromResult( + { + abort() { + abortCalls += 1; + }, + pipe(destination) { + destination.write(encoder.encode("
partial")); + }, + }, + {}, + "18.3.1", + ); + const reader = response.body?.getReader(); + if (!reader) throw new Error("Expected a streaming response body"); + await reader.read(); + await reader.cancel("consumer stopped"); + assertEquals(abortCalls, 1); + }); +}); diff --git a/src/react/compat/ssr-adapter/response-builder.ts b/src/react/compat/ssr-adapter/response-builder.ts index cab63763b9..6358f84ec1 100644 --- a/src/react/compat/ssr-adapter/response-builder.ts +++ b/src/react/compat/ssr-adapter/response-builder.ts @@ -1,44 +1,159 @@ import type * as React from "react"; import { getReactVersionInfo } from "../version-detector/index.ts"; -import { wrapInHTML } from "./html-wrapper.ts"; +import { createHTMLShell, wrapInHTML } from "./html-wrapper.ts"; import { renderToStreamAdapter } from "./stream-renderer.ts"; -import type { SSRResponseOptions } from "./types.ts"; +import type { HTMLWrapOptions, SSRResponseOptions, SSRResult } from "./types.ts"; function createHtmlHeaders(baseHeaders: HeadersInit | undefined, reactVersion: string): Headers { const headers = new Headers(baseHeaders); + headers.delete("Content-Length"); headers.set("Content-Type", "text/html; charset=utf-8"); headers.set("X-Content-Type-Options", "nosniff"); headers.set("X-React-Version", reactVersion); return headers; } -export async function createSSRResponse( - element: React.ReactNode, - options: SSRResponseOptions = {}, -): Promise { - const version = options.reactVersion ?? getReactVersionInfo().version; - const result = await renderToStreamAdapter(element, options); - const headers = createHtmlHeaders(options.headers, version); - - if (result.stream) { - return new Response(result.stream, { status: 200, headers }); - } - - if (!result.html) { - return new Response("Failed to render", { - status: 500, - headers: { "Content-Type": "text/plain" }, - }); - } - - const fullHtml = wrapInHTML(result.html, { +function createWrapOptions(options: SSRResponseOptions): HTMLWrapOptions { + return { title: options.title ?? "Veryfront App", meta: options.meta ?? {}, links: options.links ?? [], scripts: options.scripts ?? [], bootstrapScripts: options.bootstrapScripts ?? [], nonce: options.nonce, + }; +} + +/** Wrap a component byte stream in one complete HTML document. */ +export function wrapReadableStreamInHTML( + source: ReadableStream, + options: HTMLWrapOptions, +): ReadableStream { + const reader = source.getReader(); + const encoder = new TextEncoder(); + const { prefix, suffix } = createHTMLShell(options); + let prefixSent = false; + let sourceDone = false; + + return new ReadableStream({ + async pull(controller) { + if (!prefixSent) { + prefixSent = true; + controller.enqueue(encoder.encode(prefix)); + return; + } + if (sourceDone) return; + + try { + const chunk = await reader.read(); + if (!chunk.done) { + controller.enqueue(chunk.value); + return; + } + sourceDone = true; + controller.enqueue(encoder.encode(suffix)); + controller.close(); + } catch (error) { + sourceDone = true; + controller.error(error); + } + }, + async cancel(reason) { + sourceDone = true; + await reader.cancel(reason); + }, + }); +} + +async function pipeableToReadableStream( + result: Pick, +): Promise> { + if (!result.pipe) { + throw new TypeError("SSR pipeable result is missing its pipe function"); + } + + const { PassThrough } = await import("node:stream"); + const destination = new PassThrough(); + try { + result.pipe(destination); + } catch (error) { + result.abort?.(); + destination.destroy(); + throw error; + } + + // Deno's Readable.toWeb adapter can enqueue a buffered Node `data` event + // after the web consumer has cancelled. Drive the paused Node stream through + // its async iterator instead so cancellation has one deterministic owner. + const iterator = destination[Symbol.asyncIterator](); + let cancelled = false; + const bridged = new ReadableStream({ + async pull(controller) { + try { + const chunk = await iterator.next(); + if (cancelled) return; + if (chunk.done) { + controller.close(); + } else { + controller.enqueue(chunk.value); + } + } catch (error) { + if (!cancelled) controller.error(error); + } + }, + async cancel(reason) { + cancelled = true; + let abortError: unknown; + try { + result.abort?.(); + } catch (error) { + abortError = error; + } + destination.destroy(); + await iterator.return?.(reason); + if (abortError !== undefined) throw abortError; + }, }); + return bridged; +} + +/** Build an HTTP response from an already-created SSR result. */ +export async function createSSRResponseFromResult( + result: SSRResult, + options: SSRResponseOptions, + reactVersion: string, +): Promise { + const headers = createHtmlHeaders(options.headers, reactVersion); + const wrapOptions = createWrapOptions(options); + + if (result.stream || result.pipe) { + const componentStream = result.stream ?? await pipeableToReadableStream(result); + // React's stream renderers already emit bootstrapScripts inside their + // component stream. The document shell must not append them a second time. + const body = wrapReadableStreamInHTML(componentStream, { + ...wrapOptions, + bootstrapScripts: [], + }); + return new Response(body, { status: 200, headers }); + } + + if (result.html !== undefined) { + return new Response(wrapInHTML(result.html, wrapOptions), { + status: 200, + headers, + }); + } - return new Response(fullHtml, { status: 200, headers }); + throw new TypeError( + "SSR renderer returned no HTML, readable stream, or pipeable stream", + ); +} + +export async function createSSRResponse( + element: React.ReactNode, + options: SSRResponseOptions = {}, +): Promise { + const version = options.reactVersion ?? getReactVersionInfo().version; + const result = await renderToStreamAdapter(element, options); + return createSSRResponseFromResult(result, options, version); } diff --git a/src/react/compat/version-detector/compatibility-checker.ts b/src/react/compat/version-detector/compatibility-checker.ts index 5b69972cd0..ab1dbded56 100644 --- a/src/react/compat/version-detector/compatibility-checker.ts +++ b/src/react/compat/version-detector/compatibility-checker.ts @@ -1,4 +1,9 @@ -import type { CompatibilityCheckResult, ReactFeatures, SSRMethod } from "./types.ts"; +import type { + CompatibilityCheckResult, + ReactFeatures, + ReactVersionInfo, + SSRMethod, +} from "./types.ts"; import { getReactVersionInfo } from "./version-cache.ts"; const REACT_19_FEATURES: ReadonlySet = new Set([ @@ -18,10 +23,10 @@ const REACT_18_FEATURES: ReadonlySet = new Set([ "renderToReadableStream", ]); -export function checkVersionCompatibility( +export function checkVersionCompatibilityForInfo( + info: ReactVersionInfo, requiredFeatures: Array, ): CompatibilityCheckResult { - const info = getReactVersionInfo(); const warnings: string[] = []; const errors: string[] = []; let compatible = true; @@ -30,7 +35,8 @@ export function checkVersionCompatibility( if (info.features[feature]) continue; if (REACT_19_FEATURES.has(feature)) { - warnings.push(`Feature "${feature}" requires React 19 (current: ${info.version})`); + errors.push(`Feature "${feature}" requires React 19+ (current: ${info.version})`); + compatible = false; continue; } @@ -46,10 +52,19 @@ export function checkVersionCompatibility( return { compatible, warnings, errors }; } +export function checkVersionCompatibility( + requiredFeatures: Array, +): CompatibilityCheckResult { + return checkVersionCompatibilityForInfo( + getReactVersionInfo(), + requiredFeatures, + ); +} + export function getRecommendedSSRMethod(): SSRMethod { - const { isReact18, isReact19, features } = getReactVersionInfo(); + const { features } = getReactVersionInfo(); - if (isReact19 || (isReact18 && features.renderToReadableStream)) return "readable-stream"; - if (isReact18 && features.renderToPipeableStream) return "stream"; + if (features.renderToReadableStream) return "readable-stream"; + if (features.renderToPipeableStream) return "stream"; return "string"; } diff --git a/src/react/compat/version-detector/feature-detector.test.ts b/src/react/compat/version-detector/feature-detector.test.ts index d02b10f9b4..889b280159 100644 --- a/src/react/compat/version-detector/feature-detector.test.ts +++ b/src/react/compat/version-detector/feature-detector.test.ts @@ -1,12 +1,33 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { detectFeatures } from "./feature-detector.ts"; +import { + detectFeatures, + detectReactVersion, + detectReactVersionFromProject, +} from "./feature-detector.ts"; + +const OVERSIZED_PACKAGE_JSON_BYTES = 1_048_577; + +async function withProjectPackageJson( + packageJson: string | undefined, + run: (projectDir: string) => Promise, +): Promise { + const projectDir = await Deno.makeTempDir({ prefix: "vf-react-version-" }); + try { + if (packageJson !== undefined) { + await Deno.writeTextFile(`${projectDir}/package.json`, packageJson); + } + await run(projectDir); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } +} describe("feature-detector", () => { describe("detectFeatures", () => { - it("returns all features disabled for React 17", () => { - const features = detectFeatures(17, 0, false); + it("disables React 18+ features for React 17", () => { + const features = detectFeatures(17, 0); assertEquals(features.suspense, false); assertEquals(features.streaming, false); @@ -18,14 +39,14 @@ describe("feature-detector", () => { assertEquals(features.renderToPipeableStream, false); assertEquals(features.renderToReadableStream, false); - // Always available + // Basic SSR methods remain available. assertEquals(features.renderToString, true); assertEquals(features.renderToStaticMarkup, true); assertEquals(features.renderToNodeStream, true); }); it("enables React 18 features for major=18", () => { - const features = detectFeatures(18, 2, false); + const features = detectFeatures(18, 2); assertEquals(features.suspense, true); assertEquals(features.streaming, true); @@ -33,6 +54,7 @@ describe("feature-detector", () => { assertEquals(features.transitions, true); assertEquals(features.renderToPipeableStream, true); assertEquals(features.renderToReadableStream, true); + assertEquals(features.renderToNodeStream, true); // React 19 features still off assertEquals(features.useFormStatus, false); @@ -40,13 +62,15 @@ describe("feature-detector", () => { }); it("enables server components for React 18.3+", () => { - assertEquals(detectFeatures(18, 3, false).serverComponents, true); - assertEquals(detectFeatures(18, 2, false).serverComponents, false); - assertEquals(detectFeatures(18, 0, false).serverComponents, false); + assertEquals(detectFeatures(18, 3).serverComponents, true); + assertEquals(detectFeatures(18, 2).serverComponents, false); + assertEquals(detectFeatures(18, 0).serverComponents, false); + assertEquals(detectFeatures(19, 0).serverComponents, true); + assertEquals(detectFeatures(20, 0).serverComponents, true); }); - it("enables React 19 features when isReact19Flag is true", () => { - const features = detectFeatures(19, 0, true); + it("enables React 19 features when the version has React 19 capabilities", () => { + const features = detectFeatures(19, 0); assertEquals(features.useFormStatus, true); assertEquals(features.useOptimistic, true); @@ -57,13 +81,119 @@ describe("feature-detector", () => { // Also has React 18+ features assertEquals(features.suspense, true); assertEquals(features.streaming, true); + assertEquals(features.renderToNodeStream, false); + assertEquals(detectFeatures(18, 3).renderToNodeStream, true); }); it("treats major >= 18 as React 18+ for base features", () => { - const features = detectFeatures(20, 0, false); + const features = detectFeatures(20, 0); assertEquals(features.suspense, true); assertEquals(features.streaming, true); + assertEquals(features.serverComponents, true); + assertEquals(features.useFormStatus, true); + assertEquals(features.renderToNodeStream, false); + }); + }); + + describe("detectReactVersionFromProject", () => { + it("uses the bundled version only when package.json is absent", async () => { + await withProjectPackageJson(undefined, async (projectDir) => { + assertEquals( + (await detectReactVersionFromProject(projectDir)).version, + detectReactVersion().version, + ); + }); + }); + + it("uses the bundled version when the manifest declares no React dependency", async () => { + await withProjectPackageJson('{"name":"example"}', async (projectDir) => { + assertEquals( + (await detectReactVersionFromProject(projectDir)).version, + detectReactVersion().version, + ); + }); + }); + + it("selects the declared dependency scope deterministically", async () => { + await withProjectPackageJson( + JSON.stringify({ + dependencies: { react: "^18.2.0" }, + devDependencies: { react: "19.1.0" }, + peerDependencies: { react: ">=17.0.2" }, + }), + async (projectDir) => { + const info = await detectReactVersionFromProject(projectDir); + assertEquals(info.version, "18.2.0"); + assertEquals(info.isReact18, true); + }, + ); + }); + + it("does not enable React 19 features for a React 18 release candidate", async () => { + await withProjectPackageJson( + JSON.stringify({ dependencies: { react: "18.3.0-rc.1" } }), + async (projectDir) => { + const info = await detectReactVersionFromProject(projectDir); + assertEquals(info.isReact18, true); + assertEquals(info.isReact19, false); + assertEquals(info.features.useFormStatus, false); + assertEquals(info.features.useOptimistic, false); + }, + ); + }); + + it("rejects malformed package.json instead of silently using bundled React", async () => { + await withProjectPackageJson("{", async (projectDir) => { + await assertRejects( + () => detectReactVersionFromProject(projectDir), + Error, + "Project package.json must contain valid JSON", + ); + }); + }); + + it("rejects package.json with invalid UTF-8", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-react-version-" }); + try { + await Deno.writeFile( + `${projectDir}/package.json`, + new Uint8Array([0x7b, 0x22, 0xff, 0x22, 0x7d]), + ); + await assertRejects( + () => detectReactVersionFromProject(projectDir), + Error, + "Project package.json must use valid UTF-8", + ); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("rejects invalid React dependency values and ambiguous ranges", async () => { + for (const react of [19, "latest", "^18.2.0 || ^19.0.0"]) { + await withProjectPackageJson( + JSON.stringify({ dependencies: { react } }), + async (projectDir) => { + await assertRejects( + () => detectReactVersionFromProject(projectDir), + Error, + "React dependency", + ); + }, + ); + } + }); + + it("rejects oversized package.json before parsing it", async () => { + const packageJson = `{"name":"example"}${" ".repeat(OVERSIZED_PACKAGE_JSON_BYTES)}`; + await withProjectPackageJson(packageJson, async (projectDir) => { + await assertRejects( + () => detectReactVersionFromProject(projectDir), + Error, + "exceeds the 1 MiB limit", + ); + }); }); }); }); diff --git a/src/react/compat/version-detector/feature-detector.ts b/src/react/compat/version-detector/feature-detector.ts index ce5ca14e76..16dab6ae76 100644 --- a/src/react/compat/version-detector/feature-detector.ts +++ b/src/react/compat/version-detector/feature-detector.ts @@ -1,43 +1,63 @@ +import { join } from "#veryfront/compat/path/index.ts"; +import { CONFIG_INVALID, CONFIG_PARSE_ERROR } from "#veryfront/errors"; +import { createFileSystem, isNotFoundError } from "#veryfront/platform/compat/fs.ts"; import { rendererLogger as logger } from "#veryfront/utils"; -import { readTextFile } from "#veryfront/platform/compat/fs.ts"; import * as React from "react"; import type { ReactFeatures, ReactVersionInfo } from "./types.ts"; -import { isReact17, isReact18, isReact19, parseVersion } from "./version-parser.ts"; +import { + isReact17, + isReact18, + isReact19, + parseVersion, + resolveReactDependencyVersion, +} from "./version-parser.ts"; + +const MAX_PROJECT_PACKAGE_JSON_BYTES = 1_048_576; +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); +const REACT_DEPENDENCY_SCOPES = [ + "dependencies", + "devDependencies", + "peerDependencies", +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} export function detectFeatures( major: number, minor: number, - isReact19Flag: boolean, ): ReactFeatures { const isReact18Plus = major >= 18; - const serverComponents = isReact18Plus && minor >= 3; + const hasReact19Features = major >= 19; + const serverComponents = major > 18 || (major === 18 && minor >= 3); - return { + return Object.freeze({ suspense: isReact18Plus, streaming: isReact18Plus, automaticBatching: isReact18Plus, transitions: isReact18Plus, serverComponents, - useFormStatus: isReact19Flag, - useOptimistic: isReact19Flag, - serverActions: isReact19Flag, - improvedSuspense: isReact19Flag, - enhancedStreaming: isReact19Flag, + useFormStatus: hasReact19Features, + useOptimistic: hasReact19Features, + serverActions: hasReact19Features, + improvedSuspense: hasReact19Features, + enhancedStreaming: hasReact19Features, renderToString: true, renderToStaticMarkup: true, - renderToNodeStream: true, + renderToNodeStream: major < 19, renderToPipeableStream: isReact18Plus, renderToReadableStream: isReact18Plus, - }; + }); } function buildVersionInfo(version: string): ReactVersionInfo { const { major, minor, patch } = parseVersion(version); const react19 = isReact19(major, version); - return { + return Object.freeze({ version, major, minor, @@ -45,8 +65,8 @@ function buildVersionInfo(version: string): ReactVersionInfo { isReact17: isReact17(major), isReact18: isReact18(major), isReact19: react19, - features: detectFeatures(major, minor, react19), - }; + features: detectFeatures(major, minor), + }); } export function detectReactVersion(): ReactVersionInfo { @@ -58,35 +78,96 @@ export function detectReactVersion(): ReactVersionInfo { export async function detectReactVersionFromProject( projectDir: string, ): Promise { - let version = React.version; - + const packageJsonPath = join(projectDir, "package.json"); + const fs = createFileSystem(); + let packageJsonBytes: Uint8Array; try { - const packageJsonPath = `${projectDir}/package.json`; - const packageJson = JSON.parse(await readTextFile(packageJsonPath)); - const reactDep = packageJson.dependencies?.react ?? - packageJson.devDependencies?.react ?? - packageJson.peerDependencies?.react; - - if (reactDep) { - version = reactDep.replace(/^[\^~>=<]+/, ""); - logger.debug("Detected React version from package.json", { - projectDir, - version, + const packageJsonInfo = await fs.stat(packageJsonPath); + if (!packageJsonInfo.isFile) { + throw CONFIG_INVALID.create({ + detail: "Project package.json must be a regular file", }); - } else { - logger.debug("No React in package.json, using bundled version", { - projectDir, - version, + } + if (packageJsonInfo.size > MAX_PROJECT_PACKAGE_JSON_BYTES) { + throw CONFIG_INVALID.create({ + detail: "Project package.json exceeds the 1 MiB limit", }); } - } catch (_) { - logger.debug("Could not read package.json, using bundled React version", { - projectDir, - version, + packageJsonBytes = await fs.readFile(packageJsonPath); + } catch (error) { + if (!isNotFoundError(error)) throw error; + + const info = buildVersionInfo(React.version); + logger.debug("Project package.json not found; using bundled React version", info); + return info; + } + + if (packageJsonBytes.byteLength > MAX_PROJECT_PACKAGE_JSON_BYTES) { + throw CONFIG_INVALID.create({ + detail: "Project package.json exceeds the 1 MiB limit", + }); + } + + let packageJsonText: string; + try { + packageJsonText = UTF8_DECODER.decode(packageJsonBytes); + } catch (cause) { + throw CONFIG_PARSE_ERROR.create({ + detail: "Project package.json must use valid UTF-8", + cause, + }); + } + + let packageJson: unknown; + try { + packageJson = JSON.parse(packageJsonText); + } catch (cause) { + throw CONFIG_PARSE_ERROR.create({ + detail: "Project package.json must contain valid JSON", + cause, }); } + if (!isRecord(packageJson)) { + throw CONFIG_INVALID.create({ + detail: "Project package.json must contain a JSON object", + }); + } + + let dependencySpecifier: string | undefined; + let dependencyScope: typeof REACT_DEPENDENCY_SCOPES[number] | undefined; + for (const scope of REACT_DEPENDENCY_SCOPES) { + const dependencies = packageJson[scope]; + if (dependencies === undefined) continue; + if (!isRecord(dependencies)) { + throw CONFIG_INVALID.create({ + detail: `Project package.json field "${scope}" must contain a JSON object`, + }); + } + if (!Object.hasOwn(dependencies, "react")) continue; + + const candidate = dependencies.react; + if (typeof candidate !== "string" || candidate.trim().length === 0) { + throw CONFIG_INVALID.create({ + detail: `React dependency in "${scope}" must be a non-empty string`, + }); + } + dependencySpecifier = candidate; + dependencyScope = scope; + break; + } + + if (dependencySpecifier === undefined) { + const info = buildVersionInfo(React.version); + logger.debug("No React dependency declared; using bundled React version", info); + return info; + } + + const version = resolveReactDependencyVersion(dependencySpecifier); const info = buildVersionInfo(version); - logger.debug("Detected React version for project", { projectDir, ...info }); + logger.debug("Detected React version from project package.json", { + dependencyScope, + ...info, + }); return info; } diff --git a/src/react/compat/version-detector/types.ts b/src/react/compat/version-detector/types.ts index 4e1e33ff97..0c912c0465 100644 --- a/src/react/compat/version-detector/types.ts +++ b/src/react/compat/version-detector/types.ts @@ -1,30 +1,30 @@ export interface ReactVersionInfo { - version: string; - major: number; - minor: number; - patch: number; - isReact17: boolean; - isReact18: boolean; - isReact19: boolean; - features: ReactFeatures; + readonly version: string; + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly isReact17: boolean; + readonly isReact18: boolean; + readonly isReact19: boolean; + readonly features: ReactFeatures; } export interface ReactFeatures { - suspense: boolean; - streaming: boolean; - automaticBatching: boolean; - transitions: boolean; - serverComponents: boolean; - useFormStatus: boolean; - useOptimistic: boolean; - serverActions: boolean; - improvedSuspense: boolean; - enhancedStreaming: boolean; - renderToString: boolean; - renderToStaticMarkup: boolean; - renderToNodeStream: boolean; - renderToPipeableStream: boolean; - renderToReadableStream: boolean; + readonly suspense: boolean; + readonly streaming: boolean; + readonly automaticBatching: boolean; + readonly transitions: boolean; + readonly serverComponents: boolean; + readonly useFormStatus: boolean; + readonly useOptimistic: boolean; + readonly serverActions: boolean; + readonly improvedSuspense: boolean; + readonly enhancedStreaming: boolean; + readonly renderToString: boolean; + readonly renderToStaticMarkup: boolean; + readonly renderToNodeStream: boolean; + readonly renderToPipeableStream: boolean; + readonly renderToReadableStream: boolean; } export interface ParsedVersion { diff --git a/src/react/compat/version-detector/version-cache.ts b/src/react/compat/version-detector/version-cache.ts index 7c5aaabd5c..dbbf79243c 100644 --- a/src/react/compat/version-detector/version-cache.ts +++ b/src/react/compat/version-detector/version-cache.ts @@ -4,6 +4,10 @@ import { detectReactVersion, detectReactVersionFromProject } from "./feature-det let defaultVersionInfo: ReactVersionInfo | null = null; const projectVersionCache = new Map(); +function projectCacheKey(projectDir: string, projectId?: string): string { + return projectId === undefined ? `directory:${projectDir}` : `project:${projectId}`; +} + export function getReactVersionInfo(): ReactVersionInfo { defaultVersionInfo ??= detectReactVersion(); return defaultVersionInfo; @@ -13,7 +17,7 @@ export async function getReactVersionInfoForProject( projectDir: string, projectId?: string, ): Promise { - const cacheKey = projectId ?? projectDir; + const cacheKey = projectCacheKey(projectDir, projectId); const cached = projectVersionCache.get(cacheKey); if (cached) return cached; @@ -22,8 +26,9 @@ export async function getReactVersionInfoForProject( return info; } -export function clearProjectVersionCache(projectId: string): void { - projectVersionCache.delete(projectId); +export function clearProjectVersionCache(projectIdOrDir: string): void { + projectVersionCache.delete(`project:${projectIdOrDir}`); + projectVersionCache.delete(`directory:${projectIdOrDir}`); } export function hasFeature(feature: keyof ReactFeatures): boolean { diff --git a/src/react/compat/version-detector/version-detector.test.ts b/src/react/compat/version-detector/version-detector.test.ts index 5a6cc35f71..e7ccda9952 100644 --- a/src/react/compat/version-detector/version-detector.test.ts +++ b/src/react/compat/version-detector/version-detector.test.ts @@ -3,14 +3,32 @@ import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert.ts import { describe, it } from "#veryfront/testing/bdd.ts"; import { checkVersionCompatibility, + clearProjectVersionCache, detectReactVersion, getReactVersionInfo, + getReactVersionInfoForProject, getRecommendedSSRMethod, hasFeature, parseVersion, } from "./index.ts"; +import { checkVersionCompatibilityForInfo } from "./compatibility-checker.ts"; +import { detectFeatures } from "./feature-detector.ts"; +import type { ReactVersionInfo } from "./types.ts"; import { __resetReactVersionCacheForTests } from "./version-cache.ts"; +function reactVersionInfo(version: string, major: number, minor: number): ReactVersionInfo { + return { + version, + major, + minor, + patch: 0, + isReact17: major === 17, + isReact18: major === 18, + isReact19: major === 19, + features: detectFeatures(major, minor), + }; +} + describe("React Version Detector", () => { describe("Version Parsing", () => { it("parses React 19.x version", () => { @@ -87,11 +105,14 @@ describe("React Version Detector", () => { assertEquals(info.features.enhancedStreaming, true); }); - it("all versions have basic SSR capabilities", () => { + it("reports basic and legacy SSR capabilities for the detected version", () => { const info = getReactVersionInfo(); assertEquals(info.features.renderToString, true); assertEquals(info.features.renderToStaticMarkup, true); - assertEquals(info.features.renderToNodeStream, true); + assertEquals( + info.features.renderToNodeStream, + info.major < 19 && !info.isReact19, + ); }); it("hasFeature checks individual features", () => { @@ -109,7 +130,10 @@ describe("React Version Detector", () => { const info = getReactVersionInfo(); if (info.major < 18) return; - assertEquals(info.features.serverComponents, info.minor >= 3); + assertEquals( + info.features.serverComponents, + info.major > 18 || (info.major === 18 && info.minor >= 3), + ); }); }); @@ -150,6 +174,17 @@ describe("React Version Detector", () => { }); describe("Version Compatibility Checking", () => { + it("rejects unavailable required React 19 capabilities deterministically", () => { + const res = checkVersionCompatibilityForInfo( + reactVersionInfo("18.2.0", 18, 2), + ["useFormStatus", "useOptimistic"], + ); + + assertEquals(res.compatible, false); + assertEquals(res.warnings, []); + assertEquals(res.errors.length, 2); + }); + it("returns valid compatibility report structure", () => { const res = checkVersionCompatibility(["suspense", "renderToString"]); assertEquals(typeof res.compatible, "boolean"); @@ -163,12 +198,13 @@ describe("React Version Detector", () => { assertEquals(res.errors.length, 0); }); - it("generates warnings for React 19 features on older versions", () => { + it("fails closed for required React 19 features on older versions", () => { const info = getReactVersionInfo(); if (info.isReact19) return; const res = checkVersionCompatibility(["useFormStatus"]); - assertEquals(res.warnings.some((w) => w.includes("useFormStatus")), true); + assertEquals(res.compatible, false); + assertEquals(res.errors.some((error) => error.includes("useFormStatus")), true); }); it("generates errors for React 18 features on React 17", () => { @@ -193,12 +229,13 @@ describe("React Version Detector", () => { assert(res.errors.length >= 3); }); - it("categorizes React 19 features as warnings not errors", () => { + it("categorizes missing required React 19 features as errors", () => { const info = getReactVersionInfo(); if (info.isReact19) return; const res = checkVersionCompatibility(["useOptimistic", "serverActions"]); - assert(res.warnings.length >= 2); + assertEquals(res.compatible, false); + assert(res.errors.length >= 2); }); }); @@ -210,6 +247,54 @@ describe("React Version Detector", () => { assertEquals(a, b); }); + it("returns immutable cached version metadata", () => { + const info = getReactVersionInfo(); + + assertEquals(Object.isFrozen(info), true); + assertEquals(Object.isFrozen(info.features), true); + assertEquals(Reflect.set(info.features, "streaming", false), false); + }); + + it("separates project-id cache keys from directory cache keys", async () => { + const firstDir = await Deno.makeTempDir({ prefix: "vf-react-cache-a-" }); + const secondDir = await Deno.makeTempDir({ prefix: "vf-react-cache-b-" }); + try { + await Deno.writeTextFile( + `${firstDir}/package.json`, + JSON.stringify({ dependencies: { react: "17.0.2" } }), + ); + await Deno.writeTextFile( + `${secondDir}/package.json`, + JSON.stringify({ dependencies: { react: "19.1.0" } }), + ); + + assertEquals( + (await getReactVersionInfoForProject(firstDir, secondDir)).version, + "17.0.2", + ); + assertEquals( + (await getReactVersionInfoForProject(secondDir)).version, + "19.1.0", + ); + + await Deno.writeTextFile( + `${firstDir}/package.json`, + JSON.stringify({ dependencies: { react: "18.2.0" } }), + ); + clearProjectVersionCache(secondDir); + assertEquals( + (await getReactVersionInfoForProject(firstDir, secondDir)).version, + "18.2.0", + ); + } finally { + __resetReactVersionCacheForTests(); + await Promise.all([ + Deno.remove(firstDir, { recursive: true }), + Deno.remove(secondDir, { recursive: true }), + ]); + } + }); + it("cache reset function exists for testing", () => { assertEquals(typeof __resetReactVersionCacheForTests, "function"); __resetReactVersionCacheForTests(); diff --git a/src/react/compat/version-detector/version-parser.test.ts b/src/react/compat/version-detector/version-parser.test.ts index 1899867f01..13b2c1ae9e 100644 --- a/src/react/compat/version-detector/version-parser.test.ts +++ b/src/react/compat/version-detector/version-parser.test.ts @@ -1,7 +1,13 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { isReact17, isReact18, isReact19, parseVersion } from "./version-parser.ts"; +import { + isReact17, + isReact18, + isReact19, + parseVersion, + resolveReactDependencyVersion, +} from "./version-parser.ts"; describe("version-parser", () => { describe("parseVersion", () => { @@ -24,6 +30,48 @@ describe("version-parser", () => { it("throws on empty string", () => { assertThrows(() => parseVersion(""), Error); }); + + it("rejects trailing data instead of accepting a valid prefix", () => { + assertThrows(() => parseVersion("19.0.0-not-semver!"), Error); + assertThrows(() => parseVersion("19.0.0 || 20.0.0"), Error); + }); + + it("rejects non-canonical and unsafe numeric components", () => { + assertThrows(() => parseVersion("019.0.0"), Error); + assertThrows( + () => parseVersion("999999999999999999999999999999.0.0"), + Error, + ); + }); + }); + + describe("resolveReactDependencyVersion", () => { + it("resolves exact and inclusive lower-bounded dependency specs", () => { + assertEquals(resolveReactDependencyVersion("19.1.0"), "19.1.0"); + assertEquals(resolveReactDependencyVersion("^18.2.0"), "18.2.0"); + assertEquals(resolveReactDependencyVersion("~18.3.1"), "18.3.1"); + assertEquals( + resolveReactDependencyVersion(">=18.2.0 <20.0.0"), + "18.2.0", + ); + }); + + it("rejects specs without one safe capability baseline", () => { + for ( + const specifier of [ + "latest", + "<20.0.0", + ">18.2.0", + "^18.2.0 || ^19.0.0", + "npm:react@19.1.0", + ] + ) { + assertThrows( + () => resolveReactDependencyVersion(specifier), + Error, + ); + } + }); }); describe("isReact17", () => { @@ -51,8 +99,8 @@ describe("version-parser", () => { assertEquals(isReact19(19, "19.0.0"), true); }); - it("returns true for React 18 RC (pre-release of 19)", () => { - assertEquals(isReact19(18, "18.3.0-rc.1"), true); + it("does not classify a React 18 release candidate as React 19", () => { + assertEquals(isReact19(18, "18.3.0-rc.1"), false); }); it("returns false for stable React 18", () => { diff --git a/src/react/compat/version-detector/version-parser.ts b/src/react/compat/version-detector/version-parser.ts index aca5ebe1f7..37df65fb0e 100644 --- a/src/react/compat/version-detector/version-parser.ts +++ b/src/react/compat/version-detector/version-parser.ts @@ -1,23 +1,71 @@ import type { ParsedVersion } from "./types.ts"; -import { createError, toError } from "#veryfront/errors"; +import { CONFIG_INVALID } from "#veryfront/errors"; + +const MAX_REACT_VERSION_LENGTH = 256; +const NUMERIC_IDENTIFIER = String.raw`(?:0|[1-9]\d*)`; +const PRERELEASE_IDENTIFIER = String.raw`(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)`; +const SEMVER_SOURCE = + String.raw`${NUMERIC_IDENTIFIER}\.${NUMERIC_IDENTIFIER}\.${NUMERIC_IDENTIFIER}` + + String.raw`(?:-${PRERELEASE_IDENTIFIER}(?:\.${PRERELEASE_IDENTIFIER})*)?` + + String.raw`(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`; +const SEMVER_PATTERN = new RegExp(`^${SEMVER_SOURCE}$`); +const SUPPORTED_DEPENDENCY_SPECIFIER_PATTERN = new RegExp( + `^(?:\\^|~|>=|=)?\\s*(${SEMVER_SOURCE})(?:\\s+<=?\\s*${SEMVER_SOURCE})?$`, +); + +function invalidReactVersion(detail: string): Error { + return CONFIG_INVALID.create({ detail }); +} export function parseVersion(versionString: string): ParsedVersion { - const match = /^(\d+)\.(\d+)\.(\d+)/.exec(versionString); - - if (!match || !match[1] || !match[2] || !match[3]) { - throw toError( - createError({ - type: "config", - message: `Invalid React version format: ${versionString}`, - }), + if ( + versionString.length > MAX_REACT_VERSION_LENGTH || + !SEMVER_PATTERN.test(versionString) + ) { + throw invalidReactVersion("React version must be a valid semantic version"); + } + + const [core] = versionString.split(/[-+]/, 1); + const parts = core?.split(".").map(Number); + if ( + !parts || + parts.length !== 3 || + parts.some((part) => !Number.isSafeInteger(part)) + ) { + throw invalidReactVersion("React version contains an unsafe numeric component"); + } + + return { major: parts[0]!, minor: parts[1]!, patch: parts[2]! }; +} + +/** + * Resolve the lowest version guaranteed by one supported npm-style specifier. + * + * The detector deliberately rejects tags, protocols, disjunctions, and + * upper-bound-only ranges because none of them identifies a safe capability + * baseline without a package-manager resolver. + */ +export function resolveReactDependencyVersion(specifier: string): string { + const normalized = specifier.trim(); + if ( + normalized.length === 0 || + normalized.length > MAX_REACT_VERSION_LENGTH + ) { + throw invalidReactVersion( + "React dependency must contain one bounded semantic-version specifier", + ); + } + + const match = SUPPORTED_DEPENDENCY_SPECIFIER_PATTERN.exec(normalized); + const version = match?.[1]; + if (!version) { + throw invalidReactVersion( + "React dependency must use an exact, caret, tilde, or inclusive lower-bounded semantic version", ); } - return { - major: parseInt(match[1], 10), - minor: parseInt(match[2], 10), - patch: parseInt(match[3], 10), - }; + parseVersion(version); + return version; } export function isReact17(major: number): boolean { @@ -28,6 +76,6 @@ export function isReact18(major: number): boolean { return major === 18; } -export function isReact19(major: number, version: string): boolean { - return major === 19 || (major === 18 && version.includes("rc")); +export function isReact19(major: number, _version: string): boolean { + return major === 19; } diff --git a/src/react/components/AppWrapper.test.tsx b/src/react/components/AppWrapper.test.tsx new file mode 100644 index 0000000000..0e5ddcf94c --- /dev/null +++ b/src/react/components/AppWrapper.test.tsx @@ -0,0 +1,57 @@ +import { renderToString } from "react-dom/server"; +import { assertEquals, assertInstanceOf, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { AppWrapper, MdxWrapperRenderError } from "./AppWrapper.tsx"; + +const bundle = { + compiledCode: "export default function Wrapper() {}", +}; + +function captureWrapperError(render: () => string): MdxWrapperRenderError { + let error: unknown; + try { + render(); + } catch (cause) { + error = cause; + } + assertInstanceOf(error, MdxWrapperRenderError); + return error; +} + +describe("AppWrapper", () => { + it("passes children through when no compiled wrapper is configured", () => { + assertEquals( + renderToString( + +
content
+
, + ), + "
content
", + ); + }); + + it("fails closed with an actionable typed error for compiled layouts", () => { + const error = captureWrapperError(() => + renderToString( + +
content
+
, + ) + ); + assertStringIncludes(error.message, "mdxRenderer.loadModuleESM"); + assertEquals(error.code, "VF_REACT_ASYNC_MDX_REQUIRED"); + assertEquals(error.wrapperKind, "layout"); + }); + + it("fails closed instead of bypassing a compiled provider", () => { + const error = captureWrapperError(() => + renderToString( + +
content
+
, + ) + ); + assertStringIncludes(error.message, "MDXWrapper or default"); + assertEquals(error.wrapperKind, "provider"); + }); +}); diff --git a/src/react/components/AppWrapper.tsx b/src/react/components/AppWrapper.tsx index ab01dfa473..5218879f1c 100644 --- a/src/react/components/AppWrapper.tsx +++ b/src/react/components/AppWrapper.tsx @@ -4,13 +4,13 @@ import type { MdxBundle } from "./LayoutComponent.tsx"; import { LayoutComponent } from "./LayoutComponent.tsx"; import { ProviderComponent } from "./ProviderComponent.tsx"; +export { type MdxWrapperKind, MdxWrapperRenderError } from "./mdx-wrapper-error.ts"; + export interface AppWrapperProps { children: React.ReactNode; providers?: MdxBundle[]; layout?: MdxBundle; components?: MDXComponents; - mode?: string; - studioEnabled?: boolean; pageContext?: TypedPageContext; } diff --git a/src/react/components/LayoutComponent.tsx b/src/react/components/LayoutComponent.tsx index 8f58dd950c..c54fca0dc2 100644 --- a/src/react/components/LayoutComponent.tsx +++ b/src/react/components/LayoutComponent.tsx @@ -1,8 +1,6 @@ import type React from "react"; -import { useMemo, useRef } from "react"; -import { mdxRenderer } from "#veryfront/transforms/mdx/index.ts"; import type { MdxBundle, MDXComponents, PageContext as TypedPageContext } from "#veryfront/types"; -import { rendererLogger as logger } from "#veryfront/utils"; +import { rejectSynchronousMdxWrapper } from "./mdx-wrapper-error.ts"; export type { MdxBundle } from "#veryfront/types"; @@ -13,50 +11,8 @@ interface LayoutComponentProps { pageContext?: TypedPageContext; } -function useStableObject(obj: T): T { - const ref = useRef(obj); - const prevSerialized = useRef(JSON.stringify(obj)); - - const serialized = JSON.stringify(obj); - if (prevSerialized.current === serialized) return ref.current; - - ref.current = obj; - prevSerialized.current = serialized; - return ref.current; -} - -export function LayoutComponent({ - mdxBundle, - children, - components = {}, - pageContext, -}: LayoutComponentProps): React.ReactElement { - const stableFrontmatter = useStableObject(mdxBundle.frontmatter); - const stablePageContext = useStableObject(pageContext); - - const fallback = <>{children}; - - const element = useMemo(() => { - try { - return mdxRenderer.render(mdxBundle.compiledCode, { - components, - frontmatter: { ...(stableFrontmatter ?? {}), pageContext: stablePageContext }, - globals: mdxBundle.globals, - extractLayout: true, - children, - }); - } catch (error) { - logger.error("[LayoutComponent] Render failed:", error); - return fallback; - } - }, [ - mdxBundle.compiledCode, - mdxBundle.globals, - components, - children, - stableFrontmatter, - stablePageContext, - ]); - - return element || fallback; +export function LayoutComponent( + _props: LayoutComponentProps, +): React.ReactElement { + return rejectSynchronousMdxWrapper("layout"); } diff --git a/src/react/components/ProviderComponent.tsx b/src/react/components/ProviderComponent.tsx index cf76960e8a..14b226bdb7 100644 --- a/src/react/components/ProviderComponent.tsx +++ b/src/react/components/ProviderComponent.tsx @@ -1,9 +1,7 @@ import type * as React from "react"; -import { useMemo, useRef } from "react"; -import { mdxRenderer } from "#veryfront/transforms/mdx/index.ts"; import type { MDXComponents } from "#veryfront/types"; import type { MdxBundle } from "./LayoutComponent.tsx"; -import { rendererLogger as logger } from "#veryfront/utils"; +import { rejectSynchronousMdxWrapper } from "./mdx-wrapper-error.ts"; interface ProviderComponentProps { mdxBundle: MdxBundle; @@ -11,48 +9,8 @@ interface ProviderComponentProps { components?: MDXComponents; } -function useStableFrontmatter( - frontmatter: MdxBundle["frontmatter"], -): MdxBundle["frontmatter"] { - const ref = useRef(frontmatter); - const serialized = JSON.stringify(frontmatter); - const prevSerialized = useRef(serialized); - - if (prevSerialized.current !== serialized) { - ref.current = frontmatter; - prevSerialized.current = serialized; - } - - return ref.current; -} - -export function ProviderComponent({ - mdxBundle, - children, - components = {}, -}: ProviderComponentProps): React.ReactElement { - const stableFrontmatter = useStableFrontmatter(mdxBundle.frontmatter); - - const element = useMemo(() => { - try { - return mdxRenderer.render(mdxBundle.compiledCode, { - components, - frontmatter: stableFrontmatter, - globals: mdxBundle.globals, - extractLayout: true, - children, - }); - } catch (error) { - logger.error("[ProviderComponent] Render failed:", error); - return null; - } - }, [ - children, - components, - mdxBundle.compiledCode, - mdxBundle.globals, - stableFrontmatter, - ]); - - return element ?? <>{children}; +export function ProviderComponent( + _props: ProviderComponentProps, +): React.ReactElement { + return rejectSynchronousMdxWrapper("provider"); } diff --git a/src/react/components/chat/agent-picker.test.tsx b/src/react/components/chat/agent-picker.test.tsx index e978009997..177e63c235 100644 --- a/src/react/components/chat/agent-picker.test.tsx +++ b/src/react/components/chat/agent-picker.test.tsx @@ -88,6 +88,35 @@ describe("AgentPicker — preset (back-compat)", () => { assertStringIncludes(html, "Lawyer Agent"); assertStringIncludes(html, "border-[var(--input-border)]"); }); + + it("represents loading rows as one disabled listbox option", async () => { + const dom = installDom(); + try { + const rootElement = document.getElementById("root"); + assert(rootElement, "root element exists"); + const root = createRoot(rootElement); + flushSync(() => { + root.render(); + }); + + const trigger = rootElement.querySelector("button"); + assert(trigger, "trigger renders"); + flushSync(() => trigger.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }))); + await settle(); + + const loading = document.querySelector('[aria-label="Loading agents"]'); + assert(loading, "loading option renders"); + assertEquals(loading.getAttribute("role"), "option"); + assertEquals(loading.getAttribute("aria-disabled"), "true"); + assertEquals(loading.getAttribute("aria-selected"), "false"); + assertEquals(loading.querySelectorAll('[aria-hidden="true"]').length, 3); + + flushSync(() => root.unmount()); + await settle(); + } finally { + dom.restore(); + } + }); }); describe("AgentPicker — composability contract", () => { diff --git a/src/react/components/chat/agent-picker.tsx b/src/react/components/chat/agent-picker.tsx index 33a536a630..2f7de3364a 100644 --- a/src/react/components/chat/agent-picker.tsx +++ b/src/react/components/chat/agent-picker.tsx @@ -121,7 +121,14 @@ function findAgent( function AgentPickerLoadingRows(): React.ReactElement { return ( - +
Loading agents {LOADING_ROW_WIDTHS.map((widthClass, index) => (
))} - +
); } -// --------------------------------------------------------------------------- -// AgentPicker — compound, render-or-compose (mirrors `ToolCall`). -// -// `` renders the -// default data-driven combobox (pill/input trigger + searchable agent list). -// Pass children to recompose the menu from `AgentPicker.Trigger`, -// `AgentPicker.Content`, `AgentPicker.List`, `AgentPicker.Item` — each reads -// `useAgentPicker()` for the shared selection + open state. Every sub-part -// takes `className` merged LAST via `cn`. The preset keeps working unchanged -// when no children are passed. -// -// The private `Popover` / `Command` primitives are composed, not modified: the -// composed tree renders a real `` (from Root) whose context flows to -// `Trigger` (a `PopoverTrigger`) and `Content` (a `PopoverContent` + `Command`), -// and `Command` context flows from `Content` down to `List` / `Item`. -// --------------------------------------------------------------------------- +// Compound render-or-compose implementation shared by the preset and addressable parts. /** Props for `AgentPicker.Trigger` — the pill/input combobox button. */ export interface AgentPickerTriggerProps { diff --git a/src/react/components/chat/chat-actions-settings.tsx b/src/react/components/chat/chat-actions-settings.tsx new file mode 100644 index 0000000000..6add4aa433 --- /dev/null +++ b/src/react/components/chat/chat-actions-settings.tsx @@ -0,0 +1,181 @@ +/** + * Private settings submenu used by the ChatActions preset. + * + * Keeping the nested floating-menu behavior separate from the public compound + * keeps ChatActions focused on composition while this module owns pointer + * transit, focus restoration, and the settings-specific presentation. + * + * @module react/components/chat/chat-actions-settings + */ +import * as React from "react"; +import { Floating } from "../ui/floating.tsx"; +import { Switch } from "../ui/switch.tsx"; +import { cn } from "./theme.ts"; + +/** The two toggle settings surfaced in the Settings submenu. */ +export interface ChatActionsSettings { + /** "Auto-send queue" — send queued messages automatically. */ + autoSubmit: boolean; + /** "Autofix errors" — attempt to fix errors automatically. */ + autoFixErrors: boolean; + /** Called with the next value when "Auto-send queue" is toggled. */ + onAutoSubmitChange: (value: boolean) => void; + /** Called with the next value when "Autofix errors" is toggled. */ + onAutoFixErrorsChange: (value: boolean) => void; +} + +function GlyphSvg({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}): React.ReactElement { + return ( + + ); +} + +function SettingsGlyph( + { className }: { className?: string }, +): React.ReactElement { + return ( + + + + + ); +} + +function ChevronRightGlyph( + { className }: { className?: string }, +): React.ReactElement { + return ( + + + + ); +} + +function SettingsToggleRow({ + label, + checked, + onCheckedChange, +}: { + label: string; + checked: boolean; + onCheckedChange: (value: boolean) => void; +}): React.ReactElement { + return ( + + ); +} + +/** + * The Settings row and its portalled submenu. + * + * The close delay lets the pointer cross the portal gap. Capturing pointer + * down inside the submenu prevents the parent menu's outside-dismiss handler + * from closing the entire action menu while a switch is being toggled. + */ +export function SettingsSubmenu({ + settings, +}: { + settings: ChatActionsSettings; +}): React.ReactElement { + const [open, setOpen] = React.useState(false); + const rowRef = React.useRef(null); + const triggerRef = React.useRef(null); + const closeTimer = React.useRef | null>(null); + + const cancelClose = React.useCallback(() => { + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }, []); + const openNow = React.useCallback(() => { + cancelClose(); + setOpen(true); + }, [cancelClose]); + const scheduleClose = React.useCallback(() => { + cancelClose(); + closeTimer.current = setTimeout(() => setOpen(false), 160); + }, [cancelClose]); + React.useEffect(() => cancelClose, [cancelClose]); + + return ( +
+ + setOpen(false)} + returnFocusRef={triggerRef} + role="menu" + onMouseEnter={openNow} + onMouseLeave={scheduleClose} + onPointerDownCapture={(event) => event.stopPropagation()} + className="z-50 min-w-[240px] overflow-hidden rounded-lg bg-[var(--popover)] p-2.5 shadow-sm outline-none" + > + + ); +} diff --git a/src/react/components/chat/chat-actions.tsx b/src/react/components/chat/chat-actions.tsx index 0ea8d2f7a0..e69b1e3f46 100644 --- a/src/react/components/chat/chat-actions.tsx +++ b/src/react/components/chat/chat-actions.tsx @@ -14,9 +14,8 @@ * - Our `dropdown-menu.tsx` has NO submenu primitive (radix `Sub*`), so the * Settings submenu is a nested `DropdownMenu` anchored to its trigger row and * portalled via `Floating` (same overlay pattern), aligned to the side. - * - Icons render a half-step smaller than Studio (`size-4` → `size-3.5`), and - * the Figma / Settings / ChevronRight glyphs are inlined here (not in the - * shared icons barrel). + * - Settings-specific glyphs stay private to the submenu implementation rather + * than expanding the shared icons barrel. * * @module react/components/chat/chat-actions */ @@ -29,198 +28,11 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu.tsx"; -import { Switch } from "../ui/switch.tsx"; -import { Floating } from "../ui/floating.tsx"; import { Button } from "../ui/button.tsx"; import { PaperclipIcon, PlusIcon } from "../ui/icons/index.ts"; import { createStrictContext } from "../create-strict-context.ts"; - -/* ------------------------------------------------------------------------------------------------- - * Inlined icons — Figma / Settings / ChevronRight are not in the shared icons - * barrel; kept local so this component adds no shared-file edits. Match the - * `size-3.5` half-step and `currentColor` stroke of the barrel icons. - * -------------------------------------------------------------------------------------------------*/ - -function GlyphSvg({ - className, - children, -}: { - className?: string; - children: React.ReactNode; -}): React.ReactElement { - return ( - - ); -} - -function SettingsGlyph( - { className }: { className?: string }, -): React.ReactElement { - return ( - - - - - ); -} - -function ChevronRightGlyph( - { className }: { className?: string }, -): React.ReactElement { - return ( - - - - ); -} - -/* ------------------------------------------------------------------------------------------------- - * Settings submenu - * -------------------------------------------------------------------------------------------------*/ - -/** The two toggle settings surfaced in the Settings submenu (forked from Studio). */ -export interface ChatActionsSettings { - /** "Auto-send queue" — send queued messages automatically. */ - autoSubmit: boolean; - /** "Autofix errors" — attempt to fix errors automatically. */ - autoFixErrors: boolean; - /** Called with the next value when "Auto-send queue" is toggled. */ - onAutoSubmitChange: (value: boolean) => void; - /** Called with the next value when "Autofix errors" is toggled. */ - onAutoFixErrorsChange: (value: boolean) => void; -} - -/** A toggle row inside the Settings submenu — label left, switch right. */ -function SettingsToggleRow({ - label, - checked, - onCheckedChange, -}: { - label: string; - checked: boolean; - onCheckedChange: (value: boolean) => void; -}): React.ReactElement { - return ( - - ); -} - -/** - * The "Settings" row + its nested submenu (a portalled `Floating` popover). - * - * The submenu is portalled to `document.body`, so it is NOT a DOM descendant of - * the row — moving the pointer from the row into the submenu would fire the - * row's `onMouseLeave` and close it before the mouse arrives. Two fixes: - * 1. **Close delay** — leaving the row schedules a close after a short grace - * period; entering the submenu (or re-entering the row) cancels it. This - * is the standard "safe transit" technique (a lighter cousin of Radix's - * pointer-safe-triangle). - * 2. **stopPropagation on the submenu's pointer-down** — the parent menu - * dismisses on outside pointer-down; without this, toggling a switch in - * the (portalled, "outside") submenu would collapse the whole menu. - */ -function SettingsSubmenu({ - settings, -}: { - settings: ChatActionsSettings; -}): React.ReactElement { - const [open, setOpen] = React.useState(false); - const rowRef = React.useRef(null); - const closeTimer = React.useRef | null>(null); - - const cancelClose = React.useCallback(() => { - if (closeTimer.current) { - clearTimeout(closeTimer.current); - closeTimer.current = null; - } - }, []); - const openNow = React.useCallback(() => { - cancelClose(); - setOpen(true); - }, [cancelClose]); - const scheduleClose = React.useCallback(() => { - cancelClose(); - closeTimer.current = setTimeout(() => setOpen(false), 160); - }, [cancelClose]); - React.useEffect(() => cancelClose, [cancelClose]); - - return ( -
- - setOpen(false)} - role="menu" - onMouseEnter={openNow} - onMouseLeave={scheduleClose} - onPointerDownCapture={(e) => e.stopPropagation()} - className="z-50 min-w-[240px] overflow-hidden rounded-lg bg-[var(--popover)] p-2.5 shadow-sm outline-none" - > - { - /* Invisible hover bridge covering the gap between the row and the - submenu, so a diagonal transit never lands on dead space. */ - } - - ); -} +import { type ChatActionsSettings, SettingsSubmenu } from "./chat-actions-settings.tsx"; +export type { ChatActionsSettings } from "./chat-actions-settings.tsx"; /* ------------------------------------------------------------------------------------------------- * ChatActions diff --git a/src/react/components/chat/chat.test.tsx b/src/react/components/chat/chat.test.tsx index a7489c11bc..894ae17be2 100644 --- a/src/react/components/chat/chat.test.tsx +++ b/src/react/components/chat/chat.test.tsx @@ -32,6 +32,8 @@ function fakeSession(overrides: Partial = {}): UseChatResult { messages: [], input: "", isLoading: false, + status: "ready", + streamingMessageId: null, error: null, model: undefined, activeModel: undefined, diff --git a/src/react/components/chat/chat/chat.characterization.test.tsx b/src/react/components/chat/chat/chat.characterization.test.tsx index e73ab2b258..88beae8d82 100644 --- a/src/react/components/chat/chat/chat.characterization.test.tsx +++ b/src/react/components/chat/chat/chat.characterization.test.tsx @@ -24,6 +24,8 @@ function fakeSession(overrides: Partial = {}): UseChatResult { messages: [], input: "", isLoading: false, + status: "ready", + streamingMessageId: null, error: null, model: undefined, activeModel: undefined, diff --git a/src/react/components/chat/chat/chat.index.test.tsx b/src/react/components/chat/chat/chat.index.test.tsx index 5473c9b3bf..de96be27f2 100644 --- a/src/react/components/chat/chat/chat.index.test.tsx +++ b/src/react/components/chat/chat/chat.index.test.tsx @@ -33,6 +33,8 @@ function fakeSession(overrides: Partial = {}): UseChatResult { messages: [], input: "", isLoading: false, + status: "ready", + streamingMessageId: null, error: null, model: undefined, activeModel: undefined, diff --git a/src/react/components/chat/chat/components/attachment-pill.test.tsx b/src/react/components/chat/chat/components/attachment-pill.test.tsx index 4a24e4832a..ed0cadd070 100644 --- a/src/react/components/chat/chat/components/attachment-pill.test.tsx +++ b/src/react/components/chat/chat/components/attachment-pill.test.tsx @@ -33,6 +33,13 @@ describe("AttachmentPill", () => { ); assertStringIncludes(html, "Remove handoff-notes.md"); }); + + it("keeps the remove control visible when reached by keyboard", () => { + const html = renderToString( + undefined} />, + ); + assertStringIncludes(html, "md:focus-visible:opacity-100"); + }); }); // The composability contract: a consuming developer must be able to recompose diff --git a/src/react/components/chat/chat/components/attachment-pill.tsx b/src/react/components/chat/chat/components/attachment-pill.tsx index 1fd719d14b..46fe64c724 100644 --- a/src/react/components/chat/chat/components/attachment-pill.tsx +++ b/src/react/components/chat/chat/components/attachment-pill.tsx @@ -408,7 +408,7 @@ function AttachmentPillRemove( onClick={() => onRemove?.(attachment.id)} aria-label={`Remove ${attachment.name}`} className={cn( - "shrink-0 opacity-100 md:opacity-0 md:group-hover:opacity-100", + "shrink-0 opacity-100 md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100", className, )} > diff --git a/src/react/components/chat/chat/components/code-block.test.tsx b/src/react/components/chat/chat/components/code-block.test.tsx index 3dbc00c9da..0ea216e834 100644 --- a/src/react/components/chat/chat/components/code-block.test.tsx +++ b/src/react/components/chat/chat/components/code-block.test.tsx @@ -1,8 +1,53 @@ +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; -import { assert, assertStringIncludes } from "#veryfront/testing/assert"; +import { JSDOM } from "npm:jsdom@28.0.0"; +import { assert, assertEquals, assertStringIncludes } from "#veryfront/testing/assert"; import { describe, it } from "#veryfront/testing/bdd"; import { RichCodeBlock } from "./code-block.tsx"; +function installDom(): { restore: () => void; window: JSDOM["window"] } { + const dom = new JSDOM('
'); + const window = dom.window; + const previous = { + window: globalThis.window, + document: globalThis.document, + navigator: globalThis.navigator, + self: globalThis.self, + Node: globalThis.Node, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + Event: globalThis.Event, + MouseEvent: globalThis.MouseEvent, + }; + + Object.assign(globalThis, { + window, + document: window.document, + navigator: window.navigator, + self: window, + Node: window.Node, + Element: window.Element, + HTMLElement: window.HTMLElement, + Event: window.Event, + MouseEvent: window.MouseEvent, + }); + + return { + window, + restore: () => { + Object.assign(globalThis, previous); + dom.window.close(); + }, + }; +} + +async function settle(): Promise { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + flushSync(() => {}); +} + describe("RichCodeBlock — inline mode", () => { it("renders an inline element with no language label or copy button", () => { const html = renderToString(); @@ -42,4 +87,89 @@ describe("RichCodeBlock — block mode", () => { ); assertStringIncludes(html, "vf-custom-block"); }); + + it("reports failed copies without leaking the fallback textarea", async () => { + const dom = installDom(); + Object.defineProperty(dom.window.navigator, "clipboard", { + configurable: true, + value: { writeText: () => Promise.reject(new Error("denied")) }, + }); + let fallbackCalls = 0; + Object.defineProperty(document, "execCommand", { + configurable: true, + value: () => { + fallbackCalls += 1; + return false; + }, + }); + + try { + const rootElement = document.getElementById("root"); + assert(rootElement, "root fixture exists"); + const root = createRoot(rootElement); + flushSync(() => root.render()); + + const button = rootElement.querySelector("button"); + assert(button, "copy control renders"); + button.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); + await settle(); + + assertEquals(fallbackCalls, 1); + assertEquals(button.textContent?.trim(), "Copy failed"); + assertEquals(button.getAttribute("aria-label"), "Copy failed"); + assertEquals( + rootElement.querySelector('[role="status"]')?.textContent, + "Unable to copy code", + ); + assertEquals(document.querySelectorAll("textarea").length, 0); + flushSync(() => root.unmount()); + } finally { + dom.restore(); + } + }); + + it("does not show stale success after the displayed code changes", async () => { + const dom = installDom(); + const pending: Array<() => void> = []; + Object.defineProperty(dom.window.navigator, "clipboard", { + configurable: true, + value: { + writeText: () => + new Promise((resolve) => { + pending.push(resolve); + }), + }, + }); + + try { + const rootElement = document.getElementById("root"); + assert(rootElement, "root fixture exists"); + const root = createRoot(rootElement); + flushSync(() => root.render()); + + const firstButton = rootElement.querySelector("button"); + assert(firstButton, "first copy control renders"); + firstButton.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); + + flushSync(() => root.render()); + const secondButton = rootElement.querySelector("button"); + assert(secondButton, "updated copy control renders"); + secondButton.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); + assertEquals(pending.length, 2); + + pending[1]?.(); + await settle(); + assertEquals(secondButton.textContent?.trim(), "Copied"); + + pending[0]?.(); + await settle(); + assertEquals(secondButton.textContent?.trim(), "Copied"); + assertStringIncludes(rootElement.textContent ?? "", "new code"); + assert(!(rootElement.textContent ?? "").includes("old code")); + + flushSync(() => root.unmount()); + } finally { + dom.restore(); + } + }); }); diff --git a/src/react/components/chat/chat/components/code-block.tsx b/src/react/components/chat/chat/components/code-block.tsx index 207d6dd3f1..06a7c76adf 100644 --- a/src/react/components/chat/chat/components/code-block.tsx +++ b/src/react/components/chat/chat/components/code-block.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useClipboardFeedback } from "../../../clipboard.ts"; import { cn } from "../../theme.ts"; import { CheckIcon, CopyIcon } from "../../../ui/icons/index.ts"; @@ -8,7 +9,7 @@ export interface CodeBlockProps { code: string; inline?: boolean; className?: string; - /** React 19: ref is a regular prop. */ + /** React 19: ref is a regular prop. Applied to the block wrapper only. */ ref?: React.Ref; } @@ -16,31 +17,25 @@ export interface CodeBlockProps { * Render rich code block. * * @deprecated Use the shared `CodeBlock` primitive (`ui/code-block.tsx`) - * instead — it does real shiki syntax highlighting, an icon-only copy button - * with tooltip, a file-type/language label, collapsible + mermaid support. This - * plain `
` fork (no highlighting) is kept only for back-compat and will be
- * removed. `Markdown` already renders fenced blocks through `CodeBlock`.
+ * instead. It provides the maintained copy/collapse surface and accepts
+ * extension-owned syntax and diagram renderers without putting third-party
+ * implementations in core. This older plain `
` fork is retained only for
+ * compatibility and will be removed.
  */
 export function RichCodeBlock(
   { language, code, inline, className, ref }: CodeBlockProps,
 ): React.ReactElement {
-  const [copied, setCopied] = React.useState(false);
+  const { outcome, copy } = useClipboardFeedback();
+  const copyStatus = outcome?.text === code ? outcome.status : undefined;
+  const copied = copyStatus === "copied";
+  const copyLabel = copied ? "Copied" : copyStatus === "failed" ? "Copy failed" : "Copy";
 
-  const handleCopy = React.useCallback(async (): Promise => {
-    try {
-      await navigator.clipboard.writeText(code);
-    } catch (_) {
-      /* expected: clipboard API unavailable, using fallback */
-      const textarea = document.createElement("textarea");
-      textarea.value = code;
-      document.body.appendChild(textarea);
-      textarea.select();
-      document.execCommand("copy");
-      document.body.removeChild(textarea);
-    }
-    setCopied(true);
-    setTimeout(() => setCopied(false), 2000);
-  }, [code]);
+  const handleCopy = React.useCallback(
+    (event: React.MouseEvent): void => {
+      void copy(code, event.currentTarget.ownerDocument);
+    },
+    [code, copy],
+  );
 
   if (inline) {
     return (
@@ -68,22 +63,30 @@ export function RichCodeBlock(
         
+        
+          {copied ? "Code copied" : copyStatus === "failed" ? "Unable to copy code" : ""}
+        
       
           {code}
diff --git a/src/react/components/chat/chat/components/message-actions.test.tsx b/src/react/components/chat/chat/components/message-actions.test.tsx
index e402a21a38..4fb9d12cb0 100644
--- a/src/react/components/chat/chat/components/message-actions.test.tsx
+++ b/src/react/components/chat/chat/components/message-actions.test.tsx
@@ -90,7 +90,7 @@ describe("MessageActionBar", () => {
   it("renders the composed copied-state leaf after copying", async () => {
     const dom = installDom();
     const writes: string[] = [];
-    Object.defineProperty(globalThis.navigator, "clipboard", {
+    Object.defineProperty(dom.window.navigator, "clipboard", {
       configurable: true,
       value: { writeText: (value: string) => Promise.resolve(writes.push(value)) },
     });
@@ -128,6 +128,64 @@ describe("MessageActionBar", () => {
       assert(rootElement.querySelector('[data-testid="custom-copied"]'));
       assertStringIncludes(rootElement.innerHTML, "vf-copied");
 
+      flushSync(() => {
+        root.render(
+          
+            copy} />
+            copied} />
+          ,
+        );
+      });
+      assert(rootElement.querySelector('[data-testid="custom-copy"]'));
+      assert(!rootElement.querySelector('[data-testid="custom-copied"]'));
+
+      flushSync(() => root.unmount());
+    } finally {
+      dom.restore();
+    }
+  });
+
+  it("keeps the copy action available when every clipboard mechanism fails", async () => {
+    const dom = installDom();
+    Object.defineProperty(dom.window.navigator, "clipboard", {
+      configurable: true,
+      value: { writeText: () => Promise.reject(new Error("denied")) },
+    });
+    Object.defineProperty(document, "execCommand", {
+      configurable: true,
+      value: () => false,
+    });
+
+    try {
+      const rootElement = document.getElementById("root");
+      assert(rootElement, "root element exists");
+      const root = createRoot(rootElement);
+      flushSync(() => {
+        root.render(
+          
+            copy} />
+            copied} />
+          ,
+        );
+      });
+
+      const copy = rootElement.querySelector(
+        '[aria-label="Copy to clipboard"]',
+      );
+      assert(copy, "copy action renders");
+      copy.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
+      await new Promise((resolve) => setTimeout(resolve, 0));
+      flushSync(() => {});
+
+      assert(rootElement.querySelector('[data-testid="custom-copy"]'));
+      assert(!rootElement.querySelector('[data-testid="custom-copied"]'));
+      assertEquals(copy.getAttribute("aria-label"), "Unable to copy. Try again");
+      assertEquals(
+        rootElement.querySelector('[role="status"]')?.textContent,
+        "Unable to copy to clipboard",
+      );
+      assertEquals(document.querySelectorAll("textarea").length, 0);
+
       flushSync(() => root.unmount());
     } finally {
       dom.restore();
diff --git a/src/react/components/chat/chat/components/message-actions.tsx b/src/react/components/chat/chat/components/message-actions.tsx
index ad0e42b1fb..521fdd30e8 100644
--- a/src/react/components/chat/chat/components/message-actions.tsx
+++ b/src/react/components/chat/chat/components/message-actions.tsx
@@ -33,6 +33,7 @@ export interface MessageActionBarActionProps {
 interface MessageActionBarContextValue {
   content: string;
   copied: boolean;
+  copyFailed: boolean;
   onCopy: (event: React.MouseEvent) => void;
   onEdit?: (content: string) => void;
   onRegenerate?: () => void;
@@ -50,15 +51,16 @@ function MessageActionBarCopy({
   icon,
   className,
 }: MessageActionBarActionProps): React.ReactElement | null {
-  const { copied, onCopy } = useMessageActionBar();
+  const { copied, copyFailed, onCopy } = useMessageActionBar();
   if (copied) return null;
+  const label = copyFailed ? "Unable to copy. Try again" : "Copy to clipboard";
   return (
     
@@ -142,18 +144,33 @@ function MessageActionBarRoot({
   onRegenerate,
   ref,
 }: MessageActionBarProps): React.ReactElement {
-  const { copied, copy } = useClipboard();
-  const doCopy = React.useCallback(() => void copy(content), [copy, content]);
+  const clipboard = useClipboard();
+  const isCurrentContent = clipboard.text === content;
+  const copied = isCurrentContent && clipboard.copied;
+  const copyFailed = isCurrentContent && clipboard.failed;
+  const doCopy = React.useCallback(
+    (ownerDocument?: Document) => void clipboard.copy(content, ownerDocument),
+    [clipboard.copy, content],
+  );
   const handleCopy = React.useCallback(
     (event: React.MouseEvent) => {
-      if (onCopy) onCopy(event, doCopy);
-      else doCopy();
+      const ownerDocument = event.currentTarget.ownerDocument;
+      const next = (): void => doCopy(ownerDocument);
+      if (onCopy) onCopy(event, next);
+      else next();
     },
     [doCopy, onCopy],
   );
   const context = React.useMemo(
-    () => ({ content, copied, onCopy: handleCopy, onEdit, onRegenerate }),
-    [content, copied, handleCopy, onEdit, onRegenerate],
+    () => ({
+      content,
+      copied,
+      copyFailed,
+      onCopy: handleCopy,
+      onEdit,
+      onRegenerate,
+    }),
+    [content, copied, copyFailed, handleCopy, onEdit, onRegenerate],
   );
 
   return (
@@ -173,6 +190,14 @@ function MessageActionBarRoot({
             
           
         )}
+        
+          {copied ? "Copied to clipboard" : copyFailed ? "Unable to copy to clipboard" : ""}
+        
       
); diff --git a/src/react/components/chat/chat/components/sidebar.test.tsx b/src/react/components/chat/chat/components/sidebar.test.tsx index c031132c1b..ff516f0a15 100644 --- a/src/react/components/chat/chat/components/sidebar.test.tsx +++ b/src/react/components/chat/chat/components/sidebar.test.tsx @@ -110,6 +110,14 @@ describe("ChatSidebar — conversation-native", () => { document.getElementById("root")!.innerHTML.includes("Controlled chat"), "lists the controlled conversation", ); + const currentConversation = document.querySelector( + 'button[aria-current="page"]', + ); + assert(currentConversation, "the current conversation is a native primary action"); + assert( + currentConversation.textContent?.includes("Controlled chat"), + "the primary action carries the conversation label", + ); flushSync(() => root.unmount()); await settle(); diff --git a/src/react/components/chat/chat/components/sidebar.tsx b/src/react/components/chat/chat/components/sidebar.tsx index 6052665d4f..76a2cb9912 100644 --- a/src/react/components/chat/chat/components/sidebar.tsx +++ b/src/react/components/chat/chat/components/sidebar.tsx @@ -402,7 +402,8 @@ export function ChatSidebarItem({ title={conversation.title} active={isActive || menuOpen} className={className} - onClick={() => onSelect(conversation.id)} + onActivate={() => onSelect(conversation.id)} + primaryActionProps={{ "aria-current": isActive ? "page" : undefined }} action={children ?? } /> diff --git a/src/react/components/chat/chat/components/tool-ui.test.tsx b/src/react/components/chat/chat/components/tool-ui.test.tsx index f074f20710..f33771fe81 100644 --- a/src/react/components/chat/chat/components/tool-ui.test.tsx +++ b/src/react/components/chat/chat/components/tool-ui.test.tsx @@ -43,6 +43,58 @@ describe("ToolCall", () => { assertEquals(html.includes("Parameters"), false); assertEquals(html.includes("Result"), false); }); + + it("renders hostile tool values without invoking accessors or custom serializers", () => { + let accessorCalls = 0; + let serializerCalls = 0; + const output: Record = { + count: 12n, + toJSON() { + serializerCalls += 1; + return "unsafe"; + }, + }; + Object.defineProperty(output, "secret", { + enumerable: true, + get() { + accessorCalls += 1; + return "unsafe"; + }, + }); + output.self = output; + + const tool: ChatDynamicToolPart = { + type: "dynamic-tool", + toolCallId: "tool-hostile-output", + toolName: "hostile_output", + state: "output-available", + input: undefined, + output, + }; + + const html = renderToString(); + + assertStringIncludes(html, "[Circular]"); + assertStringIncludes(html, "[Accessor omitted]"); + assertStringIncludes(html, "12"); + assertEquals(accessorCalls, 0); + assertEquals(serializerCalls, 0); + }); + + it("bounds oversized tool output before rendering", () => { + const tool: ChatDynamicToolPart = { + type: "dynamic-tool", + toolCallId: "tool-large-output", + toolName: "large_output", + state: "output-available", + input: undefined, + output: Array.from({ length: 600 }, (_, index) => index), + }; + + const html = renderToString(); + + assertStringIncludes(html, "[Truncated] 100 array items"); + }); }); // The composability contract: a consuming developer must be able to recompose diff --git a/src/react/components/chat/chat/components/tool-ui.tsx b/src/react/components/chat/chat/components/tool-ui.tsx index 576930798f..1e6a5ea658 100644 --- a/src/react/components/chat/chat/components/tool-ui.tsx +++ b/src/react/components/chat/chat/components/tool-ui.tsx @@ -16,10 +16,18 @@ import { import { Alert, AlertContent, AlertIcon } from "../../../ui/alert.tsx"; import { createStrictContext } from "../../../create-strict-context.ts"; import type { ChatDynamicToolPart, ChatToolPart } from "#veryfront/agent/react"; +import { type ChatJsonValue, toChatJsonValue } from "../../../../../chat/json-value.ts"; import { escapeHtml } from "#veryfront/utils/html-escape.ts"; import { isSkillToolPart } from "../utils/message-parts.ts"; import { getSkillToolProps, SkillTool } from "./skill-tool.tsx"; +const TOOL_VALUE_LIMITS = Object.freeze({ + maxContainerEntries: 500, + maxDepth: 12, + maxNodes: 2_000, + maxStringChars: 64 * 1024, +}); + /** Tool status configuration mapping state to label and icon */ const TOOL_STATUS_CONFIG: Record< string, @@ -95,10 +103,10 @@ export function ToolStatusBadge( * Format JSON with syntax highlighting * Note: Escapes HTML first to prevent XSS, then applies safe highlighting */ -function formatJsonWithHighlight(obj: unknown): React.ReactNode { - if (obj == null) return null; - - const jsonStr = typeof obj === "string" ? obj : JSON.stringify(obj, null, 2); +function formatJsonSnapshotWithHighlight( + snapshot: ChatJsonValue, +): React.ReactNode { + const jsonStr = typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot, null, 2); // SECURITY: Escape HTML first to prevent XSS attacks const escaped = escapeHtml(jsonStr); @@ -130,7 +138,14 @@ function formatJsonWithHighlight(obj: unknown): React.ReactNode { ); } -function renderOutputAsTable(output: unknown): React.ReactNode | null { +function formatJsonWithHighlight(obj: unknown): React.ReactNode { + if (obj == null) return null; + return formatJsonSnapshotWithHighlight( + toChatJsonValue(obj, TOOL_VALUE_LIMITS), + ); +} + +function renderOutputAsTable(output: ChatJsonValue): React.ReactNode | null { if (!Array.isArray(output) || output.length === 0) return null; const firstItem = output[0]; @@ -392,7 +407,8 @@ function ToolCallOutput( ): React.JSX.Element | null { const { tool, hasOutput } = useToolCall(); if (!hasOutput) return null; - const tableOutput = renderOutputAsTable(tool.output); + const output = toChatJsonValue(tool.output, TOOL_VALUE_LIMITS); + const tableOutput = renderOutputAsTable(output); return (
{children ?? tableOutput ?? (
- {formatJsonWithHighlight(tool.output)} + {formatJsonSnapshotWithHighlight(output)}
)}
diff --git a/src/react/components/chat/chat/composition/message-body.tsx b/src/react/components/chat/chat/composition/message-body.tsx index fb75439738..bc61a1c185 100644 --- a/src/react/components/chat/chat/composition/message-body.tsx +++ b/src/react/components/chat/chat/composition/message-body.tsx @@ -21,9 +21,9 @@ import type { Source } from "../components/sources.tsx"; /** Options shared by the default part renderer and `Message.Part`. */ interface RenderPartOptions { stepCount: number; - /** Forwarded to the answer `Markdown` — swap the code block. */ + /** Fenced-code override forwarded to the installed Markdown renderer. */ codeBlock?: (props: CodeBlockProps) => React.ReactNode; - /** Forwarded to the answer `Markdown` — override element renderers. */ + /** Element overrides forwarded to the installed Markdown renderer. */ markdownComponents?: Components; } diff --git a/src/react/components/chat/chat/composition/message.test.tsx b/src/react/components/chat/chat/composition/message.test.tsx index 314d028afd..130f2c6e54 100644 --- a/src/react/components/chat/chat/composition/message.test.tsx +++ b/src/react/components/chat/chat/composition/message.test.tsx @@ -1,4 +1,7 @@ +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; +import { JSDOM } from "npm:jsdom@28.0.0"; import { assert, assertEquals, assertStringIncludes } from "#veryfront/testing/assert"; import { describe, it } from "#veryfront/testing/bdd"; import type { ChatDynamicToolPart, ChatMessage } from "#veryfront/agent/react"; @@ -287,3 +290,68 @@ describe("Message.Tokens", () => { ]); }); }); + +describe("Message.CopyAction", () => { + it("retains document provenance when an interceptor calls next asynchronously", async () => { + const dom = new JSDOM( + '
', + ); + const window = dom.window; + const previous = { + window: globalThis.window, + document: globalThis.document, + navigator: globalThis.navigator, + self: globalThis.self, + Node: globalThis.Node, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + Event: globalThis.Event, + MouseEvent: globalThis.MouseEvent, + }; + const writes: string[] = []; + Object.defineProperty(window.navigator, "clipboard", { + configurable: true, + value: { + writeText: (text: string) => { + writes.push(text); + return Promise.resolve(); + }, + }, + }); + Object.assign(globalThis, { + window, + document: window.document, + navigator: window.navigator, + self: window, + Node: window.Node, + Element: window.Element, + HTMLElement: window.HTMLElement, + Event: window.Event, + MouseEvent: window.MouseEvent, + }); + + try { + const rootElement = document.getElementById("root"); + assert(rootElement, "root fixture exists"); + const root = createRoot(rootElement); + flushSync(() => { + root.render( + + queueMicrotask(next)} /> + , + ); + }); + const button = rootElement.querySelector("button"); + assert(button, "copy action renders"); + button.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + flushSync(() => {}); + + assertEquals(writes, ["Answer body."]); + flushSync(() => root.unmount()); + } finally { + Object.assign(globalThis, previous); + dom.window.close(); + } + }); +}); diff --git a/src/react/components/chat/chat/composition/message.tsx b/src/react/components/chat/chat/composition/message.tsx index f201d2a8a4..0d0d30350c 100644 --- a/src/react/components/chat/chat/composition/message.tsx +++ b/src/react/components/chat/chat/composition/message.tsx @@ -132,10 +132,13 @@ function MessageRoot( [getBranches, message.id], ); - const { copied, copy } = useClipboard(); + const clipboard = useClipboard(); + const isCurrentCopy = clipboard.text === textContent; + const copied = isCurrentCopy && clipboard.copied; + const copyFailed = isCurrentCopy && clipboard.failed; const onCopy = React.useCallback( - () => copy(textContent), - [copy, textContent], + (ownerDocument?: Document) => clipboard.copy(textContent, ownerDocument), + [clipboard.copy, textContent], ); const contextValue = React.useMemo( @@ -154,6 +157,7 @@ function MessageRoot( : undefined, onCopy, copied, + copyFailed, onEdit: editMessage ? (content: string) => { editMessage(message.id, content); @@ -177,6 +181,7 @@ function MessageRoot( switchBranch, onCopy, copied, + copyFailed, editMessage, onFeedbackProp, overrides.feedback, @@ -387,7 +392,7 @@ export interface MessageContentProps { className?: string; /** Swap the code block used in the answer markdown (forwarded to `Markdown`). */ codeBlock?: (props: CodeBlockProps) => React.ReactNode; - /** Override markdown element renderers (merged over the built-in defaults). */ + /** Element overrides forwarded to the installed rich-Markdown renderer. */ markdownComponents?: Components; /** * Compose the body yourself. Receives each grouped part in order; return a @@ -520,10 +525,15 @@ function ActionButton( }: MessageActionProps & { label: string; defaultIcon: React.ReactNode; - action: () => void; + action: (ownerDocument: Document) => void; }, ): React.ReactElement { - const handleClick = (e: React.MouseEvent) => onClick ? onClick(e, action) : action(); + const handleClick = (event: React.MouseEvent): void => { + const ownerDocument = event.currentTarget.ownerDocument; + const next = (): void => action(ownerDocument); + if (onClick) onClick(event, next); + else next(); + }; if (asChild) { return ( @@ -557,15 +567,17 @@ ActionButton.displayName = "Message.ActionButton"; export function MessageCopyAction( props: MessageActionProps, ): React.ReactElement | null { - const { onCopy, copied, textContent } = useMessageContext(); + const { onCopy, copied, copyFailed, textContent } = useMessageContext(); if (!textContent) return null; + const label = copied ? "Copied!" : copyFailed ? "Unable to copy. Try again" : "Copy to clipboard"; return ( : } - action={() => void onCopy()} + action={(ownerDocument) => void onCopy(ownerDocument)} /> ); } @@ -583,7 +595,7 @@ export function MessageRegenerateAction( {...props} label="Regenerate response" defaultIcon={} - action={onRegenerate} + action={() => onRegenerate()} /> ); } diff --git a/src/react/components/chat/chat/contexts/message-context.tsx b/src/react/components/chat/chat/contexts/message-context.tsx index a07be1c6d1..d1720e82e7 100644 --- a/src/react/components/chat/chat/contexts/message-context.tsx +++ b/src/react/components/chat/chat/contexts/message-context.tsx @@ -27,9 +27,11 @@ export interface MessageContextValue { onBranchNext?: () => void; // Actions - onCopy: () => Promise; + onCopy: (ownerDocument?: Document) => Promise; /** True briefly after `onCopy` — lifted here so composed layouts keep the tick. */ copied: boolean; + /** True briefly after every available clipboard mechanism fails. */ + copyFailed?: boolean; onEdit?: (content: string) => void; onRegenerate?: () => void; onFeedback?: (value: FeedbackValue) => void; diff --git a/src/react/components/chat/chat/controlled-chat.test.tsx b/src/react/components/chat/chat/controlled-chat.test.tsx index d7fd59e731..f56fc423f1 100644 --- a/src/react/components/chat/chat/controlled-chat.test.tsx +++ b/src/react/components/chat/chat/controlled-chat.test.tsx @@ -21,6 +21,8 @@ function fakeSession(overrides: Partial = {}): UseChatResult { messages: [], input: "", isLoading: false, + status: "ready", + streamingMessageId: null, error: null, model: undefined, activeModel: undefined, diff --git a/src/react/components/chat/chat/hooks/use-clipboard.ts b/src/react/components/chat/chat/hooks/use-clipboard.ts index a3bbc315ad..6e71663ca5 100644 --- a/src/react/components/chat/chat/hooks/use-clipboard.ts +++ b/src/react/components/chat/chat/hooks/use-clipboard.ts @@ -9,43 +9,35 @@ */ import * as React from "react"; +import { useClipboardFeedback } from "../../../clipboard.ts"; /** Result of {@link useClipboard}. */ export interface UseClipboardResult { /** True for `timeout` ms after a successful copy. */ copied: boolean; + /** True for `timeout` ms after every available copy mechanism fails. */ + failed: boolean; + /** Text associated with the settled feedback state. */ + text: string | undefined; /** Copy `text` to the clipboard (with a `document.execCommand` fallback). */ - copy: (text: string) => Promise; + copy: (text: string, ownerDocument?: Document) => Promise; } /** Copy-to-clipboard with a transient `copied` flag. */ export function useClipboard(timeout = 2000): UseClipboardResult { - const [copied, setCopied] = React.useState(false); - const timerRef = React.useRef | undefined>(undefined); + const { outcome, copy: copyWithFeedback } = useClipboardFeedback(timeout); - React.useEffect(() => { - return () => { - clearTimeout(timerRef.current); - }; - }, []); + const copy = React.useCallback(async ( + text: string, + ownerDocument?: Document, + ): Promise => { + await copyWithFeedback(text, ownerDocument); + }, [copyWithFeedback]); - const copy = React.useCallback(async (text: string): Promise => { - try { - await navigator.clipboard.writeText(text); - } catch (_) { - /* expected: clipboard API unavailable in older/insecure contexts */ - const textarea = document.createElement("textarea"); - textarea.value = text; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand("copy"); - document.body.removeChild(textarea); - } finally { - setCopied(true); - clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => setCopied(false), timeout); - } - }, [timeout]); - - return { copied, copy }; + return { + copied: outcome?.status === "copied", + failed: outcome?.status === "failed", + text: outcome?.text, + copy, + }; } diff --git a/src/react/components/chat/error-boundary.tsx b/src/react/components/chat/error-boundary.tsx index 7df48f818e..cde8c06cc8 100644 --- a/src/react/components/chat/error-boundary.tsx +++ b/src/react/components/chat/error-boundary.tsx @@ -25,7 +25,6 @@ export class ChatErrorBoundary extends React.Component< } override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { - console.error("[ChatErrorBoundary] Caught error:", error, errorInfo); this.props.onError?.(error, errorInfo); } @@ -99,7 +98,6 @@ export function useChatErrorHandler(): { const [error, setError] = React.useState(null); const handleError = React.useCallback((err: Error) => { - console.error("[useChatErrorHandler] Error:", err); setError(err); }, []); diff --git a/src/react/components/chat/index.ts b/src/react/components/chat/index.ts index d4965eb3b4..df61475895 100644 --- a/src/react/components/chat/index.ts +++ b/src/react/components/chat/index.ts @@ -232,6 +232,47 @@ export type { UseUploadResult, } from "./chat.tsx"; +// Compound component and headless-hook parity with the canonical chat barrel. +export { + AgentAvatar, + type AgentAvatarProps, + type AttachmentPillContextValue, + type AttachmentsPanelActionProps, + type AttachmentsPanelContextValue, + type AttachmentsPanelEmptyProps, + type AttachmentsPanelHeaderProps, + type AttachmentsPanelItemProps, + type AttachmentsPanelListProps, + type AttachmentsPanelLoadingProps, + ChatMessagesSkeleton, + type ChatMessagesSkeletonProps, + type ModelSelectorContentProps, + type ModelSelectorContextValue, + type ModelSelectorItemProps, + type ModelSelectorTriggerProps, + SourcePill, + type SourcePillProps, + type SourcesContextValue, + type SourcesListProps, + type StepIndicatorContextValue, + useAttachmentPill, + useAttachments, + type UseAttachmentsOptions, + useAttachmentsPanel, + type UseAttachmentsRequestState, + type UseAttachmentsResult, + type UseAttachmentsStorageState, + useModelSelector, + useSources, + useStepIndicator, + useStickToBottom, + type UseStickToBottomOptions, + type UseStickToBottomResult, + useUploadsRegistry, + type UseUploadsRegistryOptions, + type UseUploadsRegistryResult, +} from "./chat.tsx"; + // --------------------------------------------------------------------------- // Agent Card // --------------------------------------------------------------------------- diff --git a/src/react/components/clipboard.test.tsx b/src/react/components/clipboard.test.tsx new file mode 100644 index 0000000000..3ffcb6beb8 --- /dev/null +++ b/src/react/components/clipboard.test.tsx @@ -0,0 +1,303 @@ +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "npm:jsdom@28.0.0"; +import { assert, assertEquals, assertStrictEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { type ClipboardFeedback, copyTextToClipboard, useClipboardFeedback } from "./clipboard.ts"; + +function defineClipboard( + window: JSDOM["window"], + writeText: (text: string) => Promise, +): void { + Object.defineProperty(window.navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); +} + +function installDom(dom: JSDOM): () => void { + const window = dom.window; + const previous = { + window: globalThis.window, + document: globalThis.document, + navigator: globalThis.navigator, + self: globalThis.self, + Node: globalThis.Node, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + }; + + Object.assign(globalThis, { + window, + document: window.document, + navigator: window.navigator, + self: window, + Node: window.Node, + Element: window.Element, + HTMLElement: window.HTMLElement, + }); + + return () => { + Object.assign(globalThis, previous); + dom.window.close(); + }; +} + +async function settle(): Promise { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + flushSync(() => {}); +} + +describe("copyTextToClipboard", () => { + it("uses the Clipboard API without creating a fallback element", async () => { + const dom = new JSDOM(""); + const writes: string[] = []; + defineClipboard(dom.window, (text) => { + writes.push(text); + return Promise.resolve(); + }); + let fallbackCalls = 0; + Object.defineProperty(dom.window.document, "execCommand", { + configurable: true, + value: () => { + fallbackCalls += 1; + return true; + }, + }); + + try { + assertStrictEquals( + await copyTextToClipboard("const answer = 42;", dom.window.document), + true, + ); + assertEquals(writes, ["const answer = 42;"]); + assertEquals(fallbackCalls, 0); + assertEquals(dom.window.document.querySelectorAll("textarea").length, 0); + } finally { + dom.window.close(); + } + }); + + it("cleans up the fallback and restores focus and selection", async () => { + const dom = new JSDOM( + 'selected text', + ); + defineClipboard(dom.window, () => Promise.reject(new Error("permission denied"))); + const document = dom.window.document; + const button = document.getElementById("copy") as HTMLButtonElement; + const selected = document.getElementById("selected"); + assert(selected, "selection fixture exists"); + button.focus(); + + let fallbackValue: string | undefined; + Object.defineProperty(document, "execCommand", { + configurable: true, + value: () => { + fallbackValue = document.querySelector("textarea")?.value; + return true; + }, + }); + + try { + assertStrictEquals(await copyTextToClipboard("fallback", document), true); + assertEquals(fallbackValue, "fallback"); + assertEquals(document.querySelectorAll("textarea").length, 0); + assertStrictEquals(document.activeElement, button); + + button.blur(); + const range = document.createRange(); + range.selectNodeContents(selected); + document.getSelection()?.addRange(range); + assertEquals(document.getSelection()?.toString(), "selected text"); + assertStrictEquals(await copyTextToClipboard("fallback", document), true); + assertEquals(document.getSelection()?.toString(), "selected text"); + } finally { + dom.window.close(); + } + }); + + it("reports fallback failure and still removes its temporary textarea", async () => { + const dom = new JSDOM(""); + defineClipboard(dom.window, () => Promise.reject(new Error("permission denied"))); + const document = dom.window.document; + Object.defineProperty(document, "execCommand", { + configurable: true, + value: () => false, + }); + + try { + assertStrictEquals(await copyTextToClipboard("not copied", document), false); + assertEquals(document.querySelectorAll("textarea").length, 0); + + Object.defineProperty(document, "execCommand", { + configurable: true, + value: () => { + throw new Error("blocked"); + }, + }); + assertStrictEquals(await copyTextToClipboard("still not copied", document), false); + assertEquals(document.querySelectorAll("textarea").length, 0); + } finally { + dom.window.close(); + } + }); + + it("never crosses documents to use an unrelated global clipboard", async () => { + const globalDom = new JSDOM(""); + const targetDom = new JSDOM(""); + const restore = installDom(globalDom); + const globalWrites: string[] = []; + defineClipboard(globalDom.window, (text) => { + globalWrites.push(text); + return Promise.resolve(); + }); + let targetFallbackCalls = 0; + Object.defineProperty(targetDom.window.document, "execCommand", { + configurable: true, + value: () => { + targetFallbackCalls += 1; + return true; + }, + }); + + try { + assertStrictEquals( + await copyTextToClipboard("target only", targetDom.window.document), + true, + ); + assertEquals(globalWrites, []); + assertEquals(targetFallbackCalls, 1); + assertEquals(targetDom.window.document.querySelectorAll("textarea").length, 0); + } finally { + restore(); + targetDom.window.close(); + } + }); + + it("settles promptly on abort and consumes a late clipboard rejection", async () => { + const dom = new JSDOM(""); + let rejectWrite!: (error: Error) => void; + defineClipboard( + dom.window, + () => + new Promise((_resolve, reject) => { + rejectWrite = reject; + }), + ); + const controller = new AbortController(); + + try { + const pending = copyTextToClipboard( + "cancelled", + dom.window.document, + controller.signal, + ); + controller.abort(); + assertStrictEquals(await pending, false); + + rejectWrite(new Error("late clipboard rejection")); + await Promise.resolve(); + assertEquals(dom.window.document.querySelectorAll("textarea").length, 0); + } finally { + dom.window.close(); + } + }); +}); + +describe("useClipboardFeedback", () => { + it("lets the latest request win without running a stale fallback", async () => { + const dom = new JSDOM( + '
', + ); + const restore = installDom(dom); + const pending: Array<{ + resolve: () => void; + reject: (error: Error) => void; + }> = []; + defineClipboard(dom.window, () => + new Promise((resolve, reject) => { + pending.push({ resolve, reject }); + })); + let fallbackCalls = 0; + Object.defineProperty(document, "execCommand", { + configurable: true, + value: () => { + fallbackCalls += 1; + return true; + }, + }); + let feedback: ClipboardFeedback | undefined; + + function Harness(): React.ReactElement { + feedback = useClipboardFeedback(); + return ( + + {feedback.outcome ? `${feedback.outcome.status}:${feedback.outcome.text}` : "idle"} + + ); + } + + try { + const rootElement = document.getElementById("root"); + assert(rootElement, "root fixture exists"); + const root = createRoot(rootElement); + flushSync(() => root.render()); + assert(feedback, "hook result is available"); + + const first = feedback.copy("first", document); + const second = feedback.copy("second", document); + assertEquals(pending.length, 2); + + pending[1]?.resolve(); + assertStrictEquals(await second, true); + await settle(); + assertEquals(rootElement.textContent, "copied:second"); + + pending[0]?.reject(new Error("late rejection")); + assertStrictEquals(await first, false); + await settle(); + assertEquals(rootElement.textContent, "copied:second"); + assertEquals(fallbackCalls, 0); + + flushSync(() => root.unmount()); + } finally { + restore(); + } + }); + + it("does not publish a pending result after unmount", async () => { + const dom = new JSDOM( + '
', + ); + const restore = installDom(dom); + let release!: () => void; + defineClipboard(dom.window, () => + new Promise((resolve) => { + release = resolve; + })); + let feedback: ClipboardFeedback | undefined; + + function Harness(): null { + feedback = useClipboardFeedback(); + return null; + } + + try { + const rootElement = document.getElementById("root"); + assert(rootElement, "root fixture exists"); + const root = createRoot(rootElement); + flushSync(() => root.render()); + assert(feedback, "hook result is available"); + + const pendingCopy = feedback.copy("late", document); + flushSync(() => root.unmount()); + release(); + assertStrictEquals(await pendingCopy, false); + await settle(); + assertEquals(rootElement.textContent, ""); + } finally { + restore(); + } + }); +}); diff --git a/src/react/components/clipboard.ts b/src/react/components/clipboard.ts new file mode 100644 index 0000000000..277c9ca0b0 --- /dev/null +++ b/src/react/components/clipboard.ts @@ -0,0 +1,233 @@ +/** + * Shared clipboard write and transient feedback primitives for React controls. + * + * @module react/components/clipboard + */ + +import * as React from "react"; + +const DEFAULT_FEEDBACK_TIMEOUT_MS = 2000; + +interface ClipboardFeedbackOutcome { + status: "copied" | "failed"; + text: string; +} + +/** State returned by {@link useClipboardFeedback}. */ +export interface ClipboardFeedback { + /** The most recent settled copy outcome, or `undefined` before/while copying. */ + outcome: ClipboardFeedbackOutcome | undefined; + /** Copy text and update `outcome` only if this remains the latest request. */ + copy: (text: string, ownerDocument?: Document) => Promise; +} + +function restoreFocus(element: Element | null): void { + if (!element || !("focus" in element) || typeof element.focus !== "function") return; + + try { + element.focus({ preventScroll: true }); + } catch { + try { + element.focus(); + } catch { + // Focus restoration is best-effort and must not change copy success. + } + } +} + +function restoreSelection( + ownerDocument: Document, + ranges: readonly Range[], +): void { + const selection = ownerDocument.getSelection(); + if (!selection || ranges.length === 0) return; + + try { + selection.removeAllRanges(); + for (const range of ranges) selection.addRange(range); + } catch { + // The selected nodes may have been removed while the copy was in flight. + } +} + +function copyWithExecCommand(text: string, ownerDocument: Document): boolean { + const parent = ownerDocument.body ?? ownerDocument.documentElement; + if (!parent || typeof ownerDocument.execCommand !== "function") return false; + + const activeElement = ownerDocument.activeElement; + const selection = ownerDocument.getSelection(); + const selectedRanges: Range[] = []; + if (selection) { + for (let index = 0; index < selection.rangeCount; index += 1) { + selectedRanges.push(selection.getRangeAt(index).cloneRange()); + } + } + + let textarea: HTMLTextAreaElement | undefined; + try { + textarea = ownerDocument.createElement("textarea"); + textarea.value = text; + textarea.readOnly = true; + textarea.tabIndex = -1; + textarea.setAttribute("aria-hidden", "true"); + textarea.style.position = "fixed"; + textarea.style.inset = "0"; + textarea.style.width = "1px"; + textarea.style.height = "1px"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + parent.appendChild(textarea); + textarea.focus(); + textarea.select(); + return ownerDocument.execCommand("copy") === true; + } catch { + return false; + } finally { + if (textarea?.parentNode) textarea.parentNode.removeChild(textarea); + restoreFocus(activeElement); + restoreSelection(ownerDocument, selectedRanges); + } +} + +function resolveClipboard(ownerDocument?: Document): Clipboard | undefined { + try { + if (ownerDocument) { + return ownerDocument.defaultView?.navigator.clipboard; + } + return typeof navigator === "undefined" ? undefined : navigator.clipboard; + } catch { + return undefined; + } +} + +function resolveDocument(ownerDocument?: Document): Document | undefined { + if (ownerDocument) return ownerDocument; + return typeof document === "undefined" ? undefined : document; +} + +type ClipboardWriteResult = "aborted" | "failed" | "written"; + +function writeTextWithAbort( + clipboard: Clipboard, + text: string, + signal?: AbortSignal, +): Promise { + let write: Promise; + try { + write = Promise.resolve(clipboard.writeText(text)); + } catch { + return Promise.resolve("failed"); + } + + if (!signal) { + return write.then( + (): ClipboardWriteResult => "written", + (): ClipboardWriteResult => "failed", + ); + } + if (signal.aborted) { + void write.catch(() => undefined); + return Promise.resolve("aborted"); + } + + return new Promise((resolve) => { + let settled = false; + const finish = (result: ClipboardWriteResult): void => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", handleAbort); + resolve(result); + }; + const handleAbort = (): void => finish("aborted"); + + signal.addEventListener("abort", handleAbort, { once: true }); + void write.then( + () => finish("written"), + () => finish("failed"), + ); + }); +} + +/** + * Copy text without leaking the fallback textarea or reporting false success. + * + * Returns `false` when neither the asynchronous Clipboard API nor the legacy + * `execCommand("copy")` fallback completes successfully. An aborted request + * never starts the fallback or reports success; the Clipboard API itself does + * not expose cancellation for a write already in progress. + */ +export async function copyTextToClipboard( + text: string, + ownerDocument?: Document, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) return false; + + const clipboard = resolveClipboard(ownerDocument); + if (clipboard && typeof clipboard.writeText === "function") { + const result = await writeTextWithAbort(clipboard, text, signal); + if (result === "written") return true; + if (result === "aborted") return false; + // In insecure or denied contexts, try the synchronous browser fallback. + } + + if (signal?.aborted) return false; + const resolvedDocument = resolveDocument(ownerDocument); + if (!resolvedDocument) return false; + try { + return copyWithExecCommand(text, resolvedDocument); + } catch { + return false; + } +} + +/** + * Coordinate clipboard writes and a bounded success/failure feedback window. + * + * Later requests supersede earlier ones, and pending writes cannot update an + * unmounted component. + */ +export function useClipboardFeedback( + timeout = DEFAULT_FEEDBACK_TIMEOUT_MS, +): ClipboardFeedback { + const [outcome, setOutcome] = React.useState(); + const operationRef = React.useRef(0); + const mountedRef = React.useRef(true); + const timerRef = React.useRef | undefined>(undefined); + const controllerRef = React.useRef(undefined); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + operationRef.current += 1; + controllerRef.current?.abort(); + clearTimeout(timerRef.current); + }; + }, []); + + const copy = React.useCallback( + async (text: string, ownerDocument?: Document): Promise => { + const operation = ++operationRef.current; + controllerRef.current?.abort(); + const controller = new AbortController(); + controllerRef.current = controller; + if (mountedRef.current) setOutcome(undefined); + + const copied = await copyTextToClipboard(text, ownerDocument, controller.signal); + if (!mountedRef.current || operation !== operationRef.current) return copied; + + setOutcome({ status: copied ? "copied" : "failed", text }); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + if (mountedRef.current && operation === operationRef.current) { + setOutcome(undefined); + } + }, timeout); + return copied; + }, + [timeout], + ); + + return { outcome, copy }; +} diff --git a/src/react/components/index.ts b/src/react/components/index.ts index 95de8161bc..2bf1031de1 100644 --- a/src/react/components/index.ts +++ b/src/react/components/index.ts @@ -4,7 +4,12 @@ * @module react/components */ -export { AppWrapper, type AppWrapperProps } from "./AppWrapper.tsx"; +export { + AppWrapper, + type AppWrapperProps, + type MdxWrapperKind, + MdxWrapperRenderError, +} from "./AppWrapper.tsx"; export { Head } from "./Head.tsx"; export { MDXProvider, type MDXProviderProps, useMDXComponents } from "./MDXProvider.tsx"; export { diff --git a/src/react/components/mdx-wrapper-error.ts b/src/react/components/mdx-wrapper-error.ts new file mode 100644 index 0000000000..cd2a705e85 --- /dev/null +++ b/src/react/components/mdx-wrapper-error.ts @@ -0,0 +1,32 @@ +/** MDX wrapper kind that cannot be executed by the synchronous React facade. */ +export type MdxWrapperKind = "layout" | "provider"; + +/** + * Raised when legacy React wrapper components receive compiled MDX code. + * + * Executing a string factory during render is intentionally unsupported. The + * server rendering pipeline must load and validate the ESM module before it + * constructs the React tree. + */ +export class MdxWrapperRenderError extends Error { + override readonly name = "MdxWrapperRenderError"; + readonly code = "VF_REACT_ASYNC_MDX_REQUIRED"; + + constructor(readonly wrapperKind: MdxWrapperKind) { + const expectedExports = wrapperKind === "layout" + ? "MDXLayout, MainLayout, or default" + : "MDXWrapper or default"; + super( + `${wrapperKind === "layout" ? "LayoutComponent" : "ProviderComponent"} cannot execute ` + + `MdxBundle.compiledCode during React render. Load the compiled module with ` + + `mdxRenderer.loadModuleESM(...) in the rendering pipeline, validate its ` + + `${expectedExports} export, and render that component instead.`, + ); + } +} + +export function rejectSynchronousMdxWrapper( + wrapperKind: MdxWrapperKind, +): never { + throw new MdxWrapperRenderError(wrapperKind); +} diff --git a/src/react/components/optimized-image/OptimizedBackgroundImage.tsx b/src/react/components/optimized-image/OptimizedBackgroundImage.tsx index 0bc596ad33..500b02f519 100644 --- a/src/react/components/optimized-image/OptimizedBackgroundImage.tsx +++ b/src/react/components/optimized-image/OptimizedBackgroundImage.tsx @@ -1,9 +1,11 @@ import React from "react"; -import { RESPONSIVE_IMAGE_WIDTH_LG } from "#veryfront/utils/constants/network.ts"; -import { getOptimizedPath } from "./helpers.ts"; +import type { OptimizedImageMetadata } from "#veryfront/types"; +import { useOptimizedImageMetadata } from "../../runtime/core.ts"; +import { assertImageQuality, getOptimizedPath } from "./helpers.ts"; type Props = { src: string; + metadata?: OptimizedImageMetadata; children?: React.ReactNode; format?: "webp" | "avif" | "jpeg" | "png"; quality?: number; @@ -14,14 +16,22 @@ type Props = { export function OptimizedBackgroundImage({ src, + metadata, children, - format = "webp", - quality = 80, - size = RESPONSIVE_IMAGE_WIDTH_LG, + format, + quality, + size, className, style, }: Props): React.JSX.Element { - const optimizedSrc = getOptimizedPath(src, format, size, quality); + const resolvedMetadata = useOptimizedImageMetadata(src, metadata); + assertImageQuality(resolvedMetadata, quality); + const optimizedSrc = getOptimizedPath( + src, + resolvedMetadata, + format ?? resolvedMetadata.defaultFormat, + size, + ); return (
) => void; } -const DEFAULT_SIZES = RESPONSIVE_IMAGE_WIDTHS; -const DEFAULT_FORMATS: ("avif" | "webp" | "jpeg")[] = ["avif", "webp", "jpeg"]; - export function OptimizedImage({ src, + metadata, alt, width, height, sizes = "100vw", - formats = DEFAULT_FORMATS, - quality = 80, + formats, + quality, loading, priority = false, className, @@ -42,8 +49,10 @@ export function OptimizedImage({ onLoad, onError, }: OptimizedImageProps): React.JSX.Element { + const resolvedMetadata = useOptimizedImageMetadata(src, metadata); const loadingStrategy = priority ? "eager" : (loading ?? "lazy"); - const originalFormat = getImageExtension(src); + assertImageQuality(resolvedMetadata, quality); + const selectedFormats = formats ?? getAvailableFormats(resolvedMetadata); const imgStyle: React.CSSProperties = { ...style, @@ -54,17 +63,22 @@ export function OptimizedImage({ return ( - {formats.map((format) => ( + {selectedFormats.map((format) => ( ))} {alt} & { format?: "webp" | "avif" | "jpeg" | "png"; }): React.JSX.Element { - const srcSet = generateSrcSet(src, format, DEFAULT_SIZES, quality); - const optimizedSrc = getOptimizedPath(src, format, width ?? RESPONSIVE_IMAGE_WIDTH_LG, quality); + const resolvedMetadata = useOptimizedImageMetadata(src, metadata); + assertImageQuality(resolvedMetadata, quality); + const selectedFormat = format ?? resolvedMetadata.defaultFormat; + const srcSet = generateSrcSet(src, resolvedMetadata, selectedFormat); + const optimizedSrc = getOptimizedPath(src, resolvedMetadata, selectedFormat, width); return ( { - describe("getOptimizedPath", () => { - it("generates path with size and format", () => { - assertEquals( - getOptimizedPath("/images/photo.png", "webp", 640), - "/.veryfront/optimized-images/images/photo-640w.webp", - ); - }); - - it("strips original extension", () => { - assertEquals( - getOptimizedPath("/hero.jpg", "avif", 1024), - "/.veryfront/optimized-images/hero-1024w.avif", - ); - }); +const metadata: OptimizedImageMetadata = { + original: "images/photo.jpg", + originalSize: 100, + defaultFormat: "jpeg", + aspectRatio: 4 / 3, + engineIdentity: "test-engine@1", + quality: 73, + variants: [ + { + format: "webp", + size: 320, + width: 320, + height: 240, + path: "images/photo-320w-q73.webp", + fileSize: 10, + quality: 73, + }, + { + format: "webp", + size: 640, + width: 640, + height: 480, + path: "images/photo-640w-q73.webp", + fileSize: 20, + quality: 73, + }, + { + format: "jpeg", + size: 640, + width: 640, + height: 480, + path: "images/photo-640w-q73.jpeg", + fileSize: 30, + quality: 73, + }, + ], +}; - it("handles nested paths", () => { - assertEquals( - getOptimizedPath("/assets/blog/cover.jpeg", "webp", 320), - "/.veryfront/optimized-images/assets/blog/cover-320w.webp", - ); - }); +describe("optimized-image helpers", () => { + it("selects only paths present in the generated manifest", () => { + assertEquals( + getOptimizedPath("/images/photo.jpg", metadata, "webp", 400), + "/_vf/assets/images/images/photo-640w-q73.webp", + ); + assertEquals( + getOptimizedPath("/images/photo.jpg", metadata, "webp", 900), + "/_vf/assets/images/images/photo-640w-q73.webp", + ); }); - describe("generateSrcSet", () => { - it("generates srcset string with multiple sizes", () => { - const parts = generateSrcSet("/photo.png", "webp", [320, 640, 1024], 80).split(", "); - assertEquals(parts.length, 3); - - parts.forEach((part) => assertExists(part)); - assertEquals(parts[0].endsWith("320w"), true); - assertEquals(parts[1].endsWith("640w"), true); - assertEquals(parts[2].endsWith("1024w"), true); - }); - - it("generates single-size srcset", () => { - const srcset = generateSrcSet("/photo.png", "webp", [640], 80); - assertEquals(srcset.includes("640w"), true); - assertEquals(srcset.includes(","), false); - }); + it("builds srcset from actual manifest widths", () => { + assertEquals( + generateSrcSet("/images/photo.jpg", metadata, "webp"), + "/_vf/assets/images/images/photo-320w-q73.webp 320w, " + + "/_vf/assets/images/images/photo-640w-q73.webp 640w", + ); }); - describe("getImageExtension", () => { - it("returns extension for known image types", () => { - assertEquals(getImageExtension("/photo.png"), "png"); - assertEquals(getImageExtension("/photo.jpg"), "jpg"); - assertEquals(getImageExtension("/photo.webp"), "webp"); - }); - - it("returns jpeg for paths without extension", () => { - assertEquals(getImageExtension("/photo"), "jpeg"); - }); + it("fails when source, format, or quality was not produced", () => { + assertThrows( + () => getOptimizedPath("/images/other.jpg", metadata, "webp"), + TypeError, + "does not match", + ); + assertThrows( + () => getOptimizedPath("/images/photo.jpg", metadata, "avif"), + TypeError, + "no avif variant", + ); + assertThrows( + () => assertImageQuality(metadata, 80), + TypeError, + "manifest quality is 73", + ); + assertThrows( + () => getOptimizedPath("/images/photo.jpg", metadata, "webp", Number.NaN), + TypeError, + "width must be a positive finite number", + ); + }); - it("handles nested paths", () => { - assertEquals(getImageExtension("/images/blog/hero.avif"), "avif"); - }); + it("normalizes jpg to the encoder's jpeg identifier and MIME type", () => { + assertEquals(getImageExtension("/photo.jpg"), "jpeg"); + assertEquals(getImageExtension("/photo.jpeg"), "jpeg"); + assertEquals(getImageMimeType("jpeg"), "image/jpeg"); }); }); diff --git a/src/react/components/optimized-image/helpers.ts b/src/react/components/optimized-image/helpers.ts index b3e4c514aa..4fce233476 100644 --- a/src/react/components/optimized-image/helpers.ts +++ b/src/react/components/optimized-image/helpers.ts @@ -1,29 +1,119 @@ +import { IMAGE_OPTIMIZATION } from "#veryfront/utils/constants/build.ts"; +import { normalizeOptimizedImageSourcePath } from "#veryfront/utils/optimized-image-manifest.ts"; import { getExtensionName } from "#veryfront/utils/path-utils.ts"; +import type { + ImageVariant, + OptimizedImageFormat as ImageFormat, + OptimizedImageMetadata, +} from "#veryfront/types"; + +function encodeVariantPath(path: string, publicPath: string): string { + const encoded = path.split("/").map((segment) => encodeURIComponent(segment)).join("/"); + return `${publicPath}/${encoded}`; +} + +function assertManifestMatchesSource( + src: string, + metadata: OptimizedImageMetadata, +): void { + const sourcePath = normalizeOptimizedImageSourcePath(src); + if (sourcePath !== metadata.original) { + throw new TypeError( + `Optimized image metadata for ${JSON.stringify(metadata.original)} does not match ${ + JSON.stringify(src) + }`, + ); + } +} + +function variantsForFormat( + metadata: OptimizedImageMetadata, + format: ImageFormat, +): ImageVariant[] { + const variants = metadata.variants + .filter((variant) => variant.format === format) + .sort((left, right) => left.width - right.width); + if (variants.length === 0) { + throw new TypeError( + `Image manifest has no ${format} variant for ${JSON.stringify(metadata.original)}`, + ); + } + return variants; +} + +export function assertImageQuality( + metadata: OptimizedImageMetadata, + requestedQuality: number | undefined, +): void { + if (requestedQuality === undefined) return; + if ( + !Number.isInteger(requestedQuality) || + requestedQuality < 1 || + requestedQuality > 100 + ) { + throw new TypeError("Optimized image quality must be an integer from 1 through 100"); + } + if (requestedQuality !== metadata.quality) { + throw new TypeError( + `Optimized image quality ${requestedQuality} was not produced; manifest quality is ${metadata.quality}`, + ); + } +} + +export function getAvailableFormats( + metadata: OptimizedImageMetadata, +): ImageFormat[] { + const formats: ImageFormat[] = []; + for (const variant of metadata.variants) { + if (!formats.includes(variant.format)) formats.push(variant.format); + } + return formats; +} export function getOptimizedPath( src: string, - format: string, - size: number, - _quality: number = 80, + metadata: OptimizedImageMetadata, + format: ImageFormat, + requestedWidth?: number, + publicPath: string = IMAGE_OPTIMIZATION.PUBLIC_PATH, ): string { - const basePath = src.replace(/\.[^.]+$/, ""); - return `/.veryfront/optimized-images${basePath}-${size}w.${format}`; + assertManifestMatchesSource(src, metadata); + if ( + requestedWidth !== undefined && + (!Number.isFinite(requestedWidth) || requestedWidth <= 0) + ) { + throw new TypeError("Optimized image width must be a positive finite number"); + } + const variants = variantsForFormat(metadata, format); + const selected = requestedWidth === undefined + ? variants[variants.length - 1]! + : variants.find((variant) => variant.width >= requestedWidth) ?? + variants[variants.length - 1]!; + return encodeVariantPath(selected.path, publicPath); } export function generateSrcSet( src: string, - format: string, - sizes: readonly number[], - quality: number, + metadata: OptimizedImageMetadata, + format: ImageFormat, + publicPath: string = IMAGE_OPTIMIZATION.PUBLIC_PATH, ): string { - return sizes - .map((size) => `${getOptimizedPath(src, format, size, quality)} ${size}w`) + assertManifestMatchesSource(src, metadata); + return variantsForFormat(metadata, format) + .map((variant) => `${encodeVariantPath(variant.path, publicPath)} ${variant.width}w`) .join(", "); } -/** - * Get image file extension, defaulting to "jpeg" if none found. - */ -export function getImageExtension(src: string): string { - return getExtensionName(src) || "jpeg"; +/** Get a normalized image extension, defaulting to jpeg. */ +export function getImageExtension(src: string): ImageFormat { + const extension = getExtensionName(src).toLowerCase(); + if (extension === "jpg" || extension === "jpeg") return "jpeg"; + if (extension === "png" || extension === "webp" || extension === "avif") { + return extension; + } + return "jpeg"; +} + +export function getImageMimeType(format: ImageFormat): string { + return format === "jpeg" ? "image/jpeg" : `image/${format}`; } diff --git a/src/react/components/optimized-image/index.ts b/src/react/components/optimized-image/index.ts index d7172eda97..48d9aba28a 100644 --- a/src/react/components/optimized-image/index.ts +++ b/src/react/components/optimized-image/index.ts @@ -9,4 +9,6 @@ export { OptimizedImage } from "./OptimizedImage.tsx"; export type { OptimizedImageProps } from "./OptimizedImage.tsx"; export { SimpleOptimizedImage } from "./SimpleOptimizedImage.tsx"; export { useOptimizedImage } from "./useOptimizedImage.ts"; +export { ImageManifestProvider, useOptimizedImageMetadata } from "../../runtime/core.ts"; +export type { ImageManifestProviderProps } from "../../runtime/core.ts"; export { generateBlurDataURL, getAspectRatioPadding, ResponsiveImageContainer } from "./utils.tsx"; diff --git a/src/react/components/optimized-image/useOptimizedImage.ts b/src/react/components/optimized-image/useOptimizedImage.ts index 5ab327a950..b4d87db723 100644 --- a/src/react/components/optimized-image/useOptimizedImage.ts +++ b/src/react/components/optimized-image/useOptimizedImage.ts @@ -1,31 +1,35 @@ -import { RESPONSIVE_IMAGE_WIDTH_LG, RESPONSIVE_IMAGE_WIDTHS } from "#veryfront/utils"; -import { generateSrcSet, getImageExtension, getOptimizedPath } from "./helpers.ts"; - -const DEFAULT_SIZES = [...RESPONSIVE_IMAGE_WIDTHS]; -const DEFAULT_FORMATS: Array<"avif" | "webp" | "jpeg"> = ["avif", "webp", "jpeg"]; - -type ImageFormat = "avif" | "webp" | "jpeg" | "png"; +import type { OptimizedImageFormat as ImageFormat, OptimizedImageMetadata } from "#veryfront/types"; +import { useOptimizedImageMetadata as useManifestMetadata } from "../../runtime/core.ts"; +import { + assertImageQuality, + generateSrcSet, + getAvailableFormats, + getImageMimeType, + getOptimizedPath, +} from "./helpers.ts"; export function useOptimizedImage( src: string, + metadata?: OptimizedImageMetadata, options: { formats?: ImageFormat[]; quality?: number } = {}, ): { sources: Array<{ format: ImageFormat; srcSet: string; type: string }>; fallback: string; } { - const { formats = DEFAULT_FORMATS, quality = 80 } = options; + const resolvedMetadata = useManifestMetadata(src, metadata); + const { formats = getAvailableFormats(resolvedMetadata), quality } = options; + assertImageQuality(resolvedMetadata, quality); const sources = formats.map((format) => ({ format, - srcSet: generateSrcSet(src, format, DEFAULT_SIZES, quality), - type: `image/${format}`, + srcSet: generateSrcSet(src, resolvedMetadata, format), + type: getImageMimeType(format), })); const fallback = getOptimizedPath( src, - getImageExtension(src), - RESPONSIVE_IMAGE_WIDTH_LG, - quality, + resolvedMetadata, + resolvedMetadata.defaultFormat, ); return { sources, fallback }; diff --git a/src/react/components/optimized-image/utils.test.ts b/src/react/components/optimized-image/utils.test.ts new file mode 100644 index 0000000000..59ad8299d0 --- /dev/null +++ b/src/react/components/optimized-image/utils.test.ts @@ -0,0 +1,66 @@ +import { assertEquals, assertStringIncludes, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { generateBlurDataURL, getAspectRatioPadding } from "./utils.tsx"; + +function decodeBlurSvg(dataUrl: string): string { + const prefix = "data:image/svg+xml;base64,"; + assertStringIncludes(dataUrl, prefix); + const binary = atob(dataUrl.slice(prefix.length)); + return new TextDecoder().decode( + Uint8Array.from(binary, (character) => character.charCodeAt(0)), + ); +} + +describe("optimized image utilities", () => { + it("generates a UTF-8-safe blur placeholder and escapes SVG attributes", () => { + const svg = decodeBlurSvg( + generateBlurDataURL(12, 8, 'café & "value"'), + ); + + assertStringIncludes(svg, 'viewBox="0 0 12 8"'); + assertStringIncludes( + svg, + 'fill="café & <unsafe> "value""', + ); + assertEquals(svg.includes(""), false); + }); + + it("rejects invalid dimensions instead of emitting invalid CSS or SVG", () => { + for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + assertThrows( + () => generateBlurDataURL(value, 10), + TypeError, + "Blur image width must be a positive finite number", + ); + assertThrows( + () => getAspectRatioPadding(10, value), + TypeError, + "Image height must be a positive finite number", + ); + } + }); + + it("calculates aspect-ratio padding for valid dimensions", () => { + assertEquals(getAspectRatioPadding(16, 9), "56.25%"); + }); + + it("bounds placeholder color input", () => { + assertThrows( + () => generateBlurDataURL(10, 10, "x".repeat(1_025)), + TypeError, + "Blur image color must not exceed 1024 characters", + ); + assertThrows( + () => generateBlurDataURL(10, 10, "url(https://tracker.invalid/pixel)"), + TypeError, + "must not contain XML control characters or resource URLs", + ); + for (const forbidden of ["\u0000", "\u000b", "\ufffe", "\uffff"]) { + assertThrows( + () => generateBlurDataURL(10, 10, forbidden), + TypeError, + "must not contain XML control characters or resource URLs", + ); + } + }); +}); diff --git a/src/react/components/optimized-image/utils.tsx b/src/react/components/optimized-image/utils.tsx index 05f1c8e432..d2d5a729a1 100644 --- a/src/react/components/optimized-image/utils.tsx +++ b/src/react/components/optimized-image/utils.tsx @@ -1,16 +1,87 @@ import React from "react"; +const MAX_BLUR_COLOR_CHARS = 1_024; + +function assertPositiveDimension(value: number, name: string): void { + if (!Number.isFinite(value) || value <= 0) { + throw new TypeError(`${name} must be a positive finite number`); + } +} + +function escapeXmlAttribute(value: string): string { + return value.replace(/[&<>'"]/g, (character) => { + switch (character) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case "'": + return "'"; + default: + return """; + } + }); +} + +function encodeBase64Utf8(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function containsForbiddenXmlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0)!; + if ( + codePoint <= 0x08 || + codePoint === 0x0b || + codePoint === 0x0c || + (codePoint >= 0x0e && codePoint <= 0x1f) || + codePoint === 0xfffe || + codePoint === 0xffff + ) { + return true; + } + } + return false; +} + export function generateBlurDataURL( width: number = 10, height: number = 10, color: string = "#cccccc", ): string { + assertPositiveDimension(width, "Blur image width"); + assertPositiveDimension(height, "Blur image height"); + if (typeof color !== "string" || color.length === 0) { + throw new TypeError("Blur image color must be a non-empty string"); + } + if (color.length > MAX_BLUR_COLOR_CHARS) { + throw new TypeError( + `Blur image color must not exceed ${MAX_BLUR_COLOR_CHARS} characters`, + ); + } + if ( + containsForbiddenXmlCharacter(color) || + /\burl\s*\(/iu.test(color) + ) { + throw new TypeError( + "Blur image color must not contain XML control characters or resource URLs", + ); + } + + const escapedColor = escapeXmlAttribute(color); const svg = - ``; - return `data:image/svg+xml;base64,${btoa(svg)}`; + ``; + return `data:image/svg+xml;base64,${encodeBase64Utf8(svg)}`; } export function getAspectRatioPadding(width: number, height: number): string { + assertPositiveDimension(width, "Image width"); + assertPositiveDimension(height, "Image height"); return `${(height / width) * 100}%`; } diff --git a/src/react/components/ui/anchored-surface.tsx b/src/react/components/ui/anchored-surface.tsx index 22172bd978..0d25643a9b 100644 --- a/src/react/components/ui/anchored-surface.tsx +++ b/src/react/components/ui/anchored-surface.tsx @@ -1,8 +1,5 @@ /** * Shared behavioral machinery for Popover and DropdownMenu. - * TODO(a11y): focus trap, portal + collision-aware positioning (flip/shift), - * aria-controls, side/align offsets. - * DropdownMenu: roving focus, typeahead, Tab, aria-activedescendant, sub menus. * @module react/components/ui/anchored-surface */ import * as React from "react"; @@ -16,6 +13,13 @@ export interface AnchoredState { open: boolean; setOpen: (open: boolean) => void; anchorRef: React.RefObject; + triggerRef: React.RefObject; + defaultTriggerId: string; + defaultContentId: string; + triggerId: string; + contentId: string; + setTriggerId: React.Dispatch>; + setContentId: React.Dispatch>; } /** Props for `AnchoredTrigger` (returned by the factory). */ @@ -30,6 +34,12 @@ export interface AnchoredTriggerProps extends React.ButtonHTMLAttributes { align?: "start" | "end"; + /** Internal focus target used by Popover and DropdownMenu skins. */ + initialFocus?: true | string; +} + +function stableDomId(value: string): string { + return value.replace(/[^A-Za-z0-9_-]/g, ""); } /** @@ -57,7 +67,34 @@ export function createAnchoredSurfaceParts() { ): React.ReactElement { const { open: isOpen, setOpen } = useDisclosure({ open, defaultOpen, onOpenChange }); const anchorRef = React.useRef(null); - const ctx = React.useMemo(() => ({ open: isOpen, setOpen, anchorRef }), [isOpen, setOpen]); + const triggerRef = React.useRef(null); + const reactId = stableDomId(React.useId()); + const defaultTriggerId = `vf-anchored-${reactId}-trigger`; + const defaultContentId = `vf-anchored-${reactId}-content`; + const [triggerId, setTriggerId] = React.useState(defaultTriggerId); + const [contentId, setContentId] = React.useState(defaultContentId); + const ctx = React.useMemo( + () => ({ + open: isOpen, + setOpen, + anchorRef, + triggerRef, + defaultTriggerId, + defaultContentId, + triggerId, + contentId, + setTriggerId, + setContentId, + }), + [ + contentId, + defaultContentId, + defaultTriggerId, + isOpen, + setOpen, + triggerId, + ], + ); return ( {children} @@ -77,25 +114,53 @@ export function createAnchoredSurfaceParts() { * `Floating` warns in that case instead of silently rendering nothing. */ function AnchoredTrigger( - { children, asChild, onClick, haspopup, ref, ...props }: AnchoredTriggerProps, + { + children, + asChild, + disabled, + id, + onClick, + haspopup, + ref, + type, + ...props + }: AnchoredTriggerProps, ): React.ReactElement { const ctx = React.useContext(Context); + if (!ctx) { + throw new Error("Anchored trigger parts must be used within their root"); + } const Comp = asChild ? Slot : "button"; + const resolvedId = id ?? ctx.defaultTriggerId; + React.useLayoutEffect(() => { + ctx.setTriggerId(resolvedId); + return () => { + ctx.setTriggerId((current) => current === resolvedId ? ctx.defaultTriggerId : current); + }; + }, [ctx.defaultTriggerId, ctx.setTriggerId, resolvedId]); + const setTriggerRef = React.useCallback((element: HTMLButtonElement | null) => { + ctx.triggerRef.current = element; + ctx.anchorRef.current = element; + }, [ctx.anchorRef, ctx.triggerRef]); + const composedRef = React.useMemo( + () => composeRefs(setTriggerRef, ref), + [ref, setTriggerRef], + ); return ( ( - ctx?.anchorRef as React.Ref | undefined, - ref, - )} + aria-expanded={ctx.open} + aria-controls={ctx.contentId} + aria-disabled={asChild && disabled ? true : undefined} + disabled={asChild ? undefined : disabled} onClick={(e: React.MouseEvent) => { onClick?.(e); - // Guard ctx before reading ctx.open (trigger may render outside a Root). - if (ctx) ctx.setOpen(!ctx.open); + if (!e.defaultPrevented && !disabled) ctx.setOpen(!ctx.open); }} - {...props} > {children} @@ -104,21 +169,44 @@ export function createAnchoredSurfaceParts() { /** `Floating` wrapper with base classes. Skins extend via `className` and `role`. */ function AnchoredContent( - { children, className, align, ...props }: AnchoredContentProps, + { + children, + className, + align, + id, + initialFocus, + tabIndex, + "aria-labelledby": labelledBy, + ...props + }: AnchoredContentProps, ): React.ReactElement | null { const ctx = React.useContext(Context); - if (!ctx) return null; + if (!ctx) { + throw new Error("Anchored content parts must be used within their root"); + } + const resolvedId = id ?? ctx.defaultContentId; + React.useLayoutEffect(() => { + ctx.setContentId(resolvedId); + return () => { + ctx.setContentId((current) => current === resolvedId ? ctx.defaultContentId : current); + }; + }, [ctx.defaultContentId, ctx.setContentId, resolvedId]); return ( ctx.setOpen(false)} + initialFocus={initialFocus} + returnFocusRef={ctx.triggerRef} + id={resolvedId} + aria-labelledby={labelledBy ?? ctx.triggerId} + tabIndex={tabIndex ?? -1} className={cn( "z-50 overflow-hidden rounded-lg bg-[var(--popover)] text-[var(--foreground)] shadow-sm outline-none", className, )} - {...props} > {children} diff --git a/src/react/components/ui/code-block.test.tsx b/src/react/components/ui/code-block.test.tsx new file mode 100644 index 0000000000..a871c38d82 --- /dev/null +++ b/src/react/components/ui/code-block.test.tsx @@ -0,0 +1,374 @@ +import * as React from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { JSDOM } from "npm:jsdom@28.0.0"; +import { + assert, + assertEquals, + assertStrictEquals, + assertStringIncludes, + assertThrows, +} from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + CodeBlock, + CodeBlockRendererProvider, + type CodeDiagramRendererProps, + type CodeSyntaxRendererProps, + useClipboard, +} from "./code-block.tsx"; + +function installDom( + dom = new JSDOM('
'), +): { restore: () => void; window: JSDOM["window"] } { + const window = dom.window; + const previous = { + window: globalThis.window, + document: globalThis.document, + navigator: globalThis.navigator, + self: globalThis.self, + Node: globalThis.Node, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + Event: globalThis.Event, + MouseEvent: globalThis.MouseEvent, + }; + + Object.assign(globalThis, { + window, + document: window.document, + navigator: window.navigator, + self: window, + Node: window.Node, + Element: window.Element, + HTMLElement: window.HTMLElement, + Event: window.Event, + MouseEvent: window.MouseEvent, + }); + + return { + window, + restore: () => { + Object.assign(globalThis, previous); + dom.window.close(); + }, + }; +} + +async function settle(): Promise { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + flushSync(() => {}); +} + +describe("CodeBlock renderer boundary", () => { + it("renders escaped plain source without extension capabilities", () => { + const html = renderToString( + , + ); + + assertStringIncludes(html, 'data-vf-code-renderer="plain"'); + assertStringIncludes(html, 'class="language-mermaid"'); + assertStringIncludes(html, "<script>"); + assertEquals(html.includes("' language="mermaid" />, diff --git a/src/react/components/ui/code-block.tsx b/src/react/components/ui/code-block.tsx index 90c167819b..db33091e46 100644 --- a/src/react/components/ui/code-block.tsx +++ b/src/react/components/ui/code-block.tsx @@ -231,8 +231,8 @@ export interface CodeSurfaceProps { language: string; /** Resolved light/dark mode. */ resolvedMode: CodeBlockMode; - /** Explicit extension-owned renderer, or `null` for plain source. */ - renderer: React.ComponentType | null; + /** Explicit extension-owned renderer; omitted or `null` renders plain source. */ + renderer?: React.ComponentType | null; } /** Render through an explicit extension capability or escaped plain source. */ diff --git a/src/react/components/ui/collapsible.tsx b/src/react/components/ui/collapsible.tsx index 1275096ef2..023c404cab 100644 --- a/src/react/components/ui/collapsible.tsx +++ b/src/react/components/ui/collapsible.tsx @@ -86,7 +86,7 @@ export function CollapsibleTrigger({ aria-controls={ctx.contentId} aria-disabled={asChild && isDisabled ? true : undefined} data-state={ctx.open ? "open" : "closed"} - disabled={asChild ? undefined : isDisabled} + disabled={isDisabled} onClick={(e: React.MouseEvent) => { onClick?.(e); if (!e.defaultPrevented && !isDisabled) ctx.toggle(); diff --git a/src/react/components/ui/dialog.tsx b/src/react/components/ui/dialog.tsx index 65cec5975e..84287b9867 100644 --- a/src/react/components/ui/dialog.tsx +++ b/src/react/components/ui/dialog.tsx @@ -10,6 +10,7 @@ import { cx as cn } from "./cva.ts"; import { ScrollFade } from "./scroll-fade.tsx"; import { Button, type ButtonProps, LoadingButton } from "./button.tsx"; import { createModalSurfaceParts } from "./modal-surface.tsx"; +import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect.ts"; // Per-skin context + machinery -- distinct from Drawer's instance so a // DrawerClose nested inside a Dialog cannot accidentally close the Dialog. @@ -82,7 +83,7 @@ export function DialogTitle({ }: React.HTMLAttributes): React.ReactElement { const modal = _hook(); const resolvedId = id ?? modal.defaultTitleId; - React.useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { modal.setTitleId(resolvedId); modal.setTitlePresent(true); return () => { @@ -115,7 +116,7 @@ export function DialogDescription({ }: React.HTMLAttributes): React.ReactElement { const modal = _hook(); const resolvedId = id ?? modal.defaultDescriptionId; - React.useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { modal.setDescriptionId(resolvedId); modal.setDescriptionPresent(true); return () => { diff --git a/src/react/components/ui/dismissable-layer.ts b/src/react/components/ui/dismissable-layer.ts new file mode 100644 index 0000000000..d86dd84218 --- /dev/null +++ b/src/react/components/ui/dismissable-layer.ts @@ -0,0 +1,82 @@ +/** + * One Escape arbiter per document. Overlay primitives register their live + * surfaces here so only the visually topmost layer handles an Escape press. + */ + +interface DismissableLayer { + element: () => HTMLElement | null; + onEscape: (event: KeyboardEvent) => void; +} + +interface DocumentLayers { + layers: DismissableLayer[]; + onKeyDown: (event: KeyboardEvent) => void; +} + +const documentLayers = new WeakMap(); +const DOCUMENT_POSITION_FOLLOWING = 4; + +function topmostLayer(layers: DismissableLayer[]): DismissableLayer | undefined { + let top: DismissableLayer | undefined; + let topElement: HTMLElement | null = null; + for (const layer of layers) { + const element = layer.element(); + if (!element?.isConnected) continue; + if (!top || !topElement) { + top = layer; + topElement = element; + continue; + } + if (topElement.contains(element)) { + top = layer; + topElement = element; + continue; + } + if (element.contains(topElement)) continue; + if ( + topElement.compareDocumentPosition(element) & + DOCUMENT_POSITION_FOLLOWING + ) { + top = layer; + topElement = element; + } + } + return top; +} + +/** Register a live overlay surface and remove it when the surface closes. */ +export function registerDismissableLayer( + document: Document, + element: () => HTMLElement | null, + onEscape: (event: KeyboardEvent) => void, +): () => void { + let state = documentLayers.get(document); + if (!state) { + const layers: DismissableLayer[] = []; + const onKeyDown = (event: KeyboardEvent): void => { + if ( + event.defaultPrevented || event.key !== "Escape" || event.isComposing || + event.keyCode === 229 + ) return; + const top = topmostLayer(layers); + if (!top) return; + event.preventDefault(); + top.onEscape(event); + }; + state = { layers, onKeyDown }; + documentLayers.set(document, state); + document.addEventListener("keydown", onKeyDown); + } + + const layer = { element, onEscape }; + state.layers.push(layer); + return () => { + const current = documentLayers.get(document); + if (!current) return; + const index = current.layers.lastIndexOf(layer); + if (index >= 0) current.layers.splice(index, 1); + if (current.layers.length > 0) return; + document.removeEventListener("keydown", current.onKeyDown); + documentLayers.delete(document); + }; +} diff --git a/src/react/components/ui/drawer.tsx b/src/react/components/ui/drawer.tsx index ac15410d3f..ff9922abbc 100644 --- a/src/react/components/ui/drawer.tsx +++ b/src/react/components/ui/drawer.tsx @@ -8,6 +8,7 @@ import * as React from "react"; import { cx as cn } from "./cva.ts"; import { createModalSurfaceParts } from "./modal-surface.tsx"; +import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect.ts"; // Per-skin context + machinery -- distinct from Dialog's instance so a // DialogClose nested inside a Drawer cannot accidentally close the Drawer. @@ -74,7 +75,7 @@ export function DrawerTitle({ }: React.HTMLAttributes): React.ReactElement { const modal = _hook(); const resolvedId = id ?? modal.defaultTitleId; - React.useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { modal.setTitleId(resolvedId); modal.setTitlePresent(true); return () => { diff --git a/src/react/components/ui/dropdown-menu.tsx b/src/react/components/ui/dropdown-menu.tsx index f778fdd154..c30516b4b9 100644 --- a/src/react/components/ui/dropdown-menu.tsx +++ b/src/react/components/ui/dropdown-menu.tsx @@ -203,7 +203,7 @@ export function DropdownMenuItem({ {...(asChild ? {} : { type: "button" as const })} role="menuitem" aria-disabled={disabled || undefined} - disabled={asChild ? undefined : disabled} + disabled={disabled} tabIndex={-1} className={cn( "relative flex w-full cursor-pointer select-none items-center gap-2.5 rounded-md px-3 h-[36px] text-base font-normal text-left text-[var(--foreground)] outline-none transition-colors", diff --git a/src/react/components/ui/floating.test.tsx b/src/react/components/ui/floating.test.tsx index 05728dcc7b..e429fc19e2 100644 --- a/src/react/components/ui/floating.test.tsx +++ b/src/react/components/ui/floating.test.tsx @@ -295,6 +295,81 @@ describe("Floating SSR and hydration", () => { } }); + it("rebinds positioning and dismissal when an open trigger is replaced", async () => { + const dom = new JSDOM( + '
', + { pretendToBeVisual: true, url: "https://example.com/" }, + ); + const restore = installDom(dom); + const rootElement = document.getElementById("root"); + assert(rootElement); + const root = createRoot(rootElement); + let replaceTrigger = (): void => { + throw new Error("replacement callback was not installed"); + }; + + function ReanchoredPopover(): React.ReactElement { + const [replacement, setReplacement] = React.useState(false); + replaceTrigger = () => setReplacement(true); + const triggerRef = React.useCallback( + (element: HTMLButtonElement | null) => { + if (!element) return; + const left = replacement ? 200 : 10; + Object.defineProperty(element, "getBoundingClientRect", { + configurable: true, + value: () => ({ + bottom: 50, + height: 30, + left, + right: left + 40, + top: 20, + width: 40, + x: left, + y: 20, + toJSON: () => ({}), + }), + }); + }, + [replacement], + ); + return ( +
+ + + {replacement ? "Replacement" : "Initial"} + + + Content + + +
+ ); + } + + try { + flushSync(() => root.render()); + await waitFor(() => + document.querySelector("[data-reanchored-surface]")?.style.left === "10px" + ); + + flushSync(replaceTrigger); + await waitFor(() => + document.querySelector("[data-reanchored-surface]")?.style.left === "200px" + ); + const replacement = [...document.querySelectorAll("button")].find((button) => + button.textContent === "Replacement" + ); + assert(replacement); + replacement.dispatchEvent( + new dom.window.MouseEvent("mousedown", { bubbles: true, cancelable: true }), + ); + assert(document.querySelector("[data-reanchored-surface]")); + } finally { + await unmount(root); + restore(); + } + }); + it("uses the anchor owner document for portals and dismissal listeners", async () => { const globalDom = new JSDOM( '
', diff --git a/src/react/components/ui/floating.tsx b/src/react/components/ui/floating.tsx index b4046f751a..10bb321b4e 100644 --- a/src/react/components/ui/floating.tsx +++ b/src/react/components/ui/floating.tsx @@ -12,7 +12,9 @@ import * as React from "react"; import { createPortal } from "react-dom"; import { UI_SCOPE_SELECTOR } from "./design-tokens.ts"; +import { registerDismissableLayer } from "./dismissable-layer.ts"; import { focusFirst, focusWithoutScroll } from "./focus-management.ts"; +import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect.ts"; const VIEWPORT_PADDING_PX = 8; @@ -25,6 +27,8 @@ let warnedMissingAnchor = false; export interface FloatingProps extends React.HTMLAttributes { /** Element the surface is positioned against (usually the trigger element). */ anchorRef: React.RefObject; + /** Current anchor value when the owner tracks callback-ref replacement. */ + anchorElement?: HTMLElement | null; open: boolean; /** Horizontal edge to align to. */ align?: "start" | "end"; @@ -41,6 +45,7 @@ export interface FloatingProps extends React.HTMLAttributes { /** Portal a positioned surface anchored to `anchorRef`. */ export function Floating({ anchorRef, + anchorElement, open, align = "start", onDismiss, @@ -52,6 +57,7 @@ export function Floating({ ...rest }: FloatingProps): React.ReactElement | null { const ref = React.useRef(null); + const resolvedAnchor = anchorElement ?? anchorRef.current; // Portals have no server representation. Keep the server and the first // hydration render identical, then enable the portal after the component has // mounted in a browser. This also avoids touching `document` during SSR when @@ -75,9 +81,9 @@ export function Floating({ setPortalReady(true); }, []); - React.useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { if (!open || !portalReady) return; - const anchor = anchorRef.current; + const anchor = resolvedAnchor; if (!anchor) { if (!warnedMissingAnchor) { warnedMissingAnchor = true; @@ -141,27 +147,28 @@ export function Floating({ !anchor.contains(t) ) onDismissRef.current("pointer"); }; - const onKey = (e: KeyboardEvent) => { - if (e.defaultPrevented || e.key !== "Escape") return; - e.preventDefault(); - onDismissRef.current("escape"); - queueMicrotask(() => { - const focusTarget = returnFocusRef?.current ?? anchor; - if (focusTarget.isConnected) focusWithoutScroll(focusTarget); - }); - }; + const unregisterDismissableLayer = registerDismissableLayer( + ownerDocument, + () => ref.current, + () => { + onDismissRef.current("escape"); + queueMicrotask(() => { + const focusTarget = returnFocusRef?.current ?? anchor; + if (focusTarget.isConnected) focusWithoutScroll(focusTarget); + }); + }, + ); ownerDocument.addEventListener("mousedown", onPointer); - ownerDocument.addEventListener("keydown", onKey); return () => { if (raf) ownerWindow.cancelAnimationFrame(raf); ownerWindow.removeEventListener("scroll", update, true); ownerWindow.removeEventListener("resize", update); ownerDocument.removeEventListener("mousedown", onPointer); - ownerDocument.removeEventListener("keydown", onKey); + unregisterDismissableLayer(); }; - }, [open, portalReady, align, matchTriggerWidth, anchorRef, returnFocusRef]); + }, [open, portalReady, align, matchTriggerWidth, resolvedAnchor, returnFocusRef]); - React.useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { if (!open || !portalReady || !initialFocus) return; const surface = ref.current; if (!surface) return; @@ -178,7 +185,7 @@ export function Floating({ // resolve every `var(--…)` to nothing (transparent background, wrong text // color). The root still sits above the composer's `overflow-hidden`, so we // keep the clipping escape while staying inside the token scope. - const anchor = anchorRef.current; + const anchor = resolvedAnchor; const ownerDocument = anchor?.ownerDocument; if (!anchor || !ownerDocument) return null; const container = anchor.closest("[data-vf-modal-content]") ?? diff --git a/src/react/components/ui/focus-management.test.ts b/src/react/components/ui/focus-management.test.ts new file mode 100644 index 0000000000..581f4e3c0c --- /dev/null +++ b/src/react/components/ui/focus-management.test.ts @@ -0,0 +1,48 @@ +import { JSDOM } from "npm:jsdom@28.0.0"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { focusFirst, getFocusableElements } from "./focus-management.ts"; + +describe("focus management", () => { + it("excludes fieldset-disabled controls, hidden details, and non-tab-stop radios", () => { + const dom = new JSDOM(` +
+
+
+ + + +
+ `); + try { + const panel = dom.window.document.getElementById("panel") as HTMLElement; + assertEquals( + getFocusableElements(panel).map((element) => element.id), + ["radio-b", "action"], + ); + } finally { + dom.window.close(); + } + }); + + it("falls back to the container when a reported candidate cannot receive focus", () => { + const dom = new JSDOM( + '
', + { pretendToBeVisual: true }, + ); + try { + const document = dom.window.document; + const panel = document.getElementById("panel") as HTMLElement; + const outside = document.getElementById("outside") as HTMLElement; + const broken = document.getElementById("broken") as HTMLElement; + broken.focus = () => undefined; + outside.focus(); + + focusFirst(panel); + + assertEquals(document.activeElement, panel); + } finally { + dom.window.close(); + } + }); +}); diff --git a/src/react/components/ui/focus-management.ts b/src/react/components/ui/focus-management.ts index 4f4c7391a6..21299763a7 100644 --- a/src/react/components/ui/focus-management.ts +++ b/src/react/components/ui/focus-management.ts @@ -22,6 +22,11 @@ function isHiddenOrInert(element: HTMLElement): boolean { ) { return true; } + const closedDetails = element.closest("details:not([open])"); + if (closedDetails) { + const summary = closedDetails.querySelector(":scope > summary"); + if (!summary?.contains(element)) return true; + } const view = element.ownerDocument.defaultView; if (!view) return false; for (let current: HTMLElement | null = element; current; current = current.parentElement) { @@ -36,13 +41,31 @@ function isHiddenOrInert(element: HTMLElement): boolean { return false; } +function isTabStopRadio(element: HTMLElement): boolean { + if (element.tagName !== "INPUT") return true; + const input = element as HTMLInputElement; + if (input.type !== "radio" || input.name === "") return true; + + const root = input.getRootNode() as ParentNode; + if (typeof root.querySelectorAll !== "function") return true; + const group = [...root.querySelectorAll('input[type="radio"]')] + .filter((candidate) => + candidate.name === input.name && candidate.form === input.form && + !candidate.matches(":disabled") && candidate.tabIndex >= 0 && + !isHiddenOrInert(candidate) + ); + return (group.find((candidate) => candidate.checked) ?? group[0]) === input; +} + /** Return enabled, sequentially focusable descendants in DOM order. */ export function getFocusableElements(container: HTMLElement): HTMLElement[] { return [...container.querySelectorAll(FOCUSABLE_SELECTOR)] .filter((element) => element.tabIndex >= 0 && + !element.matches(":disabled") && element.getAttribute("aria-disabled") !== "true" && - !isHiddenOrInert(element) + !isHiddenOrInert(element) && + isTabStopRadio(element) ) .map((element, domIndex) => ({ domIndex, element })) .sort((left, right) => { @@ -66,7 +89,17 @@ export function focusWithoutScroll(element: HTMLElement): void { /** Focus the first interactive descendant, or the container as a fallback. */ export function focusFirst(container: HTMLElement): void { - focusWithoutScroll(getFocusableElements(container)[0] ?? container); + const first = getFocusableElements(container)[0]; + if (first) { + focusWithoutScroll(first); + if (container.ownerDocument.activeElement === first) return; + } + focusWithoutScroll(container); +} + +function focusOrFallback(element: HTMLElement, container: HTMLElement): void { + focusWithoutScroll(element); + if (container.ownerDocument.activeElement !== element) focusWithoutScroll(container); } /** Keep keyboard Tab navigation inside a modal container. */ @@ -87,9 +120,9 @@ export function trapTabKey( const last = focusable[focusable.length - 1]!; if (event.shiftKey && (active === first || !container.contains(active))) { event.preventDefault(); - focusWithoutScroll(last); + focusOrFallback(last, container); } else if (!event.shiftKey && (active === last || !container.contains(active))) { event.preventDefault(); - focusWithoutScroll(first); + focusOrFallback(first, container); } } diff --git a/src/react/components/ui/modal-surface.tsx b/src/react/components/ui/modal-surface.tsx index 7ea341da2f..4166391666 100644 --- a/src/react/components/ui/modal-surface.tsx +++ b/src/react/components/ui/modal-surface.tsx @@ -7,6 +7,8 @@ import * as React from "react"; import { createPortal } from "react-dom"; import { UI_SCOPE_SELECTOR } from "./design-tokens.ts"; import { type DisclosureOptions, useDisclosure } from "./disclosure.ts"; +import { registerDismissableLayer } from "./dismissable-layer.ts"; +import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect.ts"; import { focusFirst, focusWithoutScroll, trapTabKey } from "./focus-management.ts"; import { composeRefs, Slot } from "./slot.tsx"; @@ -98,14 +100,14 @@ function useModalContentEffect( const unlockScroll = lockDocumentScroll(document); const isTopModal = (): boolean => stack.at(-1) === panel; + const unregisterDismissableLayer = registerDismissableLayer( + document, + () => ref.current, + () => setOpen(false), + ); const onKey = (e: KeyboardEvent) => { if (!isTopModal() || e.defaultPrevented) return; - if (e.key === "Escape") { - e.preventDefault(); - setOpen(false); - } else { - trapTabKey(e, panel); - } + trapTabKey(e, panel); }; document.addEventListener("keydown", onKey); const onFocusIn = (event: FocusEvent) => { @@ -128,6 +130,7 @@ function useModalContentEffect( return () => { document.removeEventListener("keydown", onKey); document.removeEventListener("focusin", onFocusIn); + unregisterDismissableLayer(); const index = stack.lastIndexOf(panel); if (index >= 0) stack.splice(index, 1); if (stack.length === 0) modalStacks.delete(document); @@ -243,7 +246,7 @@ export function createModalSurfaceParts(name: string) { aria-expanded={ctx.open} aria-controls={ctx.contentId} aria-disabled={asChild && disabled ? true : undefined} - disabled={asChild ? undefined : disabled} + disabled={disabled} onClick={(e: React.MouseEvent) => { onClick?.(e); if (!e.defaultPrevented && !disabled) ctx.setOpen(true); @@ -266,7 +269,7 @@ export function createModalSurfaceParts(name: string) { type={asChild ? type : type ?? "button"} ref={ref} aria-disabled={asChild && disabled ? true : undefined} - disabled={asChild ? undefined : disabled} + disabled={disabled} onClick={(e: React.MouseEvent) => { onClick?.(e); if (!e.defaultPrevented && !disabled) ctx.setOpen(false); @@ -294,7 +297,7 @@ export function createModalSurfaceParts(name: string) { const resolvedId = id ?? ctx.defaultContentId; const [portalReady, setPortalReady] = React.useState(false); React.useEffect(() => setPortalReady(true), []); - React.useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { ctx.setContentId(resolvedId); return () => { ctx.setContentId((current) => current === resolvedId ? ctx.defaultContentId : current); diff --git a/src/react/components/ui/overlay-surfaces.test.tsx b/src/react/components/ui/overlay-surfaces.test.tsx index e04ab6d284..4b85fee82c 100644 --- a/src/react/components/ui/overlay-surfaces.test.tsx +++ b/src/react/components/ui/overlay-surfaces.test.tsx @@ -19,6 +19,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "./dropdown-menu.tsx"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover.tsx"; function installDom(dom: JSDOM): () => void { const window = dom.window; @@ -159,6 +160,12 @@ describe("modal surfaces", () => { assertEquals(trigger.getAttribute("aria-expanded"), "true"); assertEquals(dialog.getAttribute("aria-modal"), "true"); assertEquals(dialog.tabIndex, -1); + await waitFor( + () => + dialog.getAttribute("aria-labelledby") !== null && + dialog.getAttribute("aria-describedby") !== null, + "dialog labels were not registered", + ); const titleId = dialog.getAttribute("aria-labelledby"); const descriptionId = dialog.getAttribute("aria-describedby"); assert(titleId && descriptionId); @@ -317,6 +324,67 @@ describe("dropdown menu keyboard contract", () => { restore(); } }); + + it("dismisses only the topmost nested floating surface on Escape", async () => { + const dom = createDom(); + const restore = installDom(dom); + const rootElement = document.getElementById("root"); + assert(rootElement); + const root = createRoot(rootElement); + + try { + flushSync(() => { + root.render( +
+ + Open popover + + + + Open nested menu + + + Nested item + + + + +
, + ); + }); + await waitFor( + () => + document.querySelector('[role="dialog"]') !== null && + document.querySelector('[role="menu"]') !== null, + "nested surfaces did not portal", + ); + const item = document.getElementById("nested-menu-item"); + const menuTrigger = document.getElementById("nested-menu-trigger"); + assert(item && menuTrigger); + await waitFor( + () => document.activeElement === item, + "nested menu did not focus its item", + ); + + keydown(dom.window, item, "Escape"); + await waitFor( + () => document.querySelector('[role="menu"]') === null, + "Escape did not close the nested menu", + ); + assert(document.querySelector('[role="dialog"]')); + assertEquals(document.activeElement, menuTrigger); + + keydown(dom.window, menuTrigger, "Escape"); + await waitFor( + () => document.querySelector('[role="dialog"]') === null, + "second Escape did not close the outer popover", + ); + assertEquals(document.activeElement, document.getElementById("popover-trigger")); + } finally { + await unmount(root); + restore(); + } + }); }); describe("Collapsible ARIA contract", () => { diff --git a/src/react/components/ui/select.test.tsx b/src/react/components/ui/select.test.tsx index ea025682d9..e2bcf106ae 100644 --- a/src/react/components/ui/select.test.tsx +++ b/src/react/components/ui/select.test.tsx @@ -326,6 +326,10 @@ describe("Select", () => { assertEquals(alpha.getAttribute("role"), "option"); assertEquals(alpha.getAttribute("aria-selected"), "true"); assertEquals(alpha.hasAttribute("tabindex"), false); + await waitFor( + () => selectedText(trigger) === "Alpha", + "selected option registration", + ); flushSync(() => beta.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }))); assertEquals(selectedText(trigger), "Alpha"); diff --git a/src/react/components/ui/slot.test.tsx b/src/react/components/ui/slot.test.tsx index d8d269323c..d42bac65a2 100644 --- a/src/react/components/ui/slot.test.tsx +++ b/src/react/components/ui/slot.test.tsx @@ -17,6 +17,7 @@ function installDom(dom: JSDOM): () => void { Element: window.Element, HTMLElement: window.HTMLElement, HTMLButtonElement: window.HTMLButtonElement, + KeyboardEvent: window.KeyboardEvent, MouseEvent: window.MouseEvent, }; const previous = new Map(); @@ -139,4 +140,58 @@ describe("Slot", () => { restore(); } }); + + it("blocks disabled asChild activation before consumer handlers", () => { + const dom = new JSDOM( + '
', + { pretendToBeVisual: true, url: "https://example.com/start" }, + ); + const restore = installDom(dom); + const rootElement = document.getElementById("root"); + assert(rootElement); + const root = createRoot(rootElement); + let consumerCalls = 0; + + try { + flushSync(() => { + root.render( + consumerCalls += 1}> + consumerCalls += 1} + onClick={() => consumerCalls += 1} + > + Disabled link + + , + ); + }); + const link = document.querySelector("a"); + assert(link); + assertEquals(link.getAttribute("aria-disabled"), "true"); + assertEquals(link.tabIndex, -1); + + const click = new dom.window.MouseEvent("click", { + bubbles: true, + cancelable: true, + }); + link.dispatchEvent(click); + assertEquals(click.defaultPrevented, true); + assertEquals(consumerCalls, 0); + assertEquals(dom.window.location.pathname, "/start"); + + const enter = new dom.window.KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Enter", + }); + link.dispatchEvent(enter); + assertEquals(enter.defaultPrevented, true); + assertEquals(consumerCalls, 0); + } finally { + flushSync(() => root.unmount()); + restore(); + } + }); }); diff --git a/src/react/components/ui/slot.tsx b/src/react/components/ui/slot.tsx index 43e5474b4b..9f99f70cb5 100644 --- a/src/react/components/ui/slot.tsx +++ b/src/react/components/ui/slot.tsx @@ -137,13 +137,34 @@ function mergeProps(slotProps: AnyProps, childProps: AnyProps): AnyProps { /** Props accepted by ``. */ export interface SlotProps extends React.HTMLAttributes { children?: React.ReactNode; + /** Block activation when an asChild consumer uses non-native disabled markup. */ + disabled?: boolean; } +function preventDisabledActivation(event: React.SyntheticEvent): void { + event.preventDefault(); + event.stopPropagation(); +} + +function preventDisabledKeyboardActivation(event: React.KeyboardEvent): void { + if (event.key === "Enter" || event.key === " ") preventDisabledActivation(event); +} + +const NATIVELY_DISABLEABLE_ELEMENTS = new Set([ + "button", + "fieldset", + "input", + "optgroup", + "option", + "select", + "textarea", +]); + /** Render `Slot` — merge props onto its single child element. */ export const Slot: React.ForwardRefExoticComponent< SlotProps & React.RefAttributes > = React.forwardRef( - function Slot({ children, ...slotProps }, forwardedRef) { + function Slot({ children, disabled = false, ...slotProps }, forwardedRef) { if (!React.isValidElement(children)) { throw new TypeError("Slot requires exactly one valid React element child"); } @@ -156,6 +177,21 @@ export const Slot: React.ForwardRefExoticComponent< ); const merged = mergeProps(slotProps as AnyProps, childProps); + if (disabled) { + merged["aria-disabled"] = true; + merged.tabIndex = -1; + merged.onAuxClickCapture = preventDisabledActivation; + merged.onClickCapture = preventDisabledActivation; + merged.onKeyDownCapture = preventDisabledKeyboardActivation; + if ( + typeof child.type === "string" && + NATIVELY_DISABLEABLE_ELEMENTS.has(child.type) + ) { + merged.disabled = true; + } else { + delete merged.disabled; + } + } merged.ref = mergedRef; return React.cloneElement( child, diff --git a/src/react/components/ui/tooltip.tsx b/src/react/components/ui/tooltip.tsx index 6007c108d1..cbc52785bb 100644 --- a/src/react/components/ui/tooltip.tsx +++ b/src/react/components/ui/tooltip.tsx @@ -15,6 +15,7 @@ import * as React from "react"; import { createPortal } from "react-dom"; import { cx as cn } from "./cva.ts"; import { UI_SCOPE_SELECTOR } from "./design-tokens.ts"; +import { registerDismissableLayer } from "./dismissable-layer.ts"; type Side = "top" | "bottom" | "left" | "right"; @@ -332,18 +333,6 @@ export function Tooltip( }; }, [generatedContentId]); - useIsomorphicLayoutEffect(() => { - if (!interactions.open || !triggerElement) return; - const ownerDocument = triggerElement.ownerDocument; - const onKeyDown = (event: KeyboardEvent): void => { - if (event.key === "Escape" && !event.defaultPrevented) { - interactions.dismiss(); - } - }; - ownerDocument.addEventListener("keydown", onKeyDown); - return () => ownerDocument.removeEventListener("keydown", onKeyDown); - }, [interactions.dismiss, interactions.open, triggerElement]); - const value = React.useMemo( () => ({ contentId, @@ -841,6 +830,7 @@ export function TooltipContent( const registerContent = context?.registerContent; const open = context?.open ?? false; const triggerElement = context?.triggerElement ?? null; + const dismiss = context?.dismiss; const offset = normalizeSideOffset(sideOffset); React.useEffect(() => { @@ -869,6 +859,16 @@ export function TooltipContent( ); }, [offset, open, portalReady, side, triggerElement]); + useIsomorphicLayoutEffect(() => { + const content = ref.current; + if (!open || !portalReady || !content || !dismiss) return; + return registerDismissableLayer( + content.ownerDocument, + () => ref.current, + dismiss, + ); + }, [dismiss, open, portalReady]); + if (!open || !portalReady || !triggerElement) return null; const ownerDocument = triggerElement.ownerDocument; const container = triggerElement.closest(UI_SCOPE_SELECTOR) ?? diff --git a/src/react/components/ui/use-isomorphic-layout-effect.ts b/src/react/components/ui/use-isomorphic-layout-effect.ts new file mode 100644 index 0000000000..de831f68cc --- /dev/null +++ b/src/react/components/ui/use-isomorphic-layout-effect.ts @@ -0,0 +1,9 @@ +import * as React from "react"; + +/** + * Use a layout effect in browsers without emitting React's server-rendering + * warning when a primitive is rendered on the server. + */ +export const useIsomorphicLayoutEffect = typeof globalThis.document === "undefined" + ? React.useEffect + : React.useLayoutEffect; diff --git a/src/react/context/image-manifest.test.tsx b/src/react/context/image-manifest.test.tsx deleted file mode 100644 index 396e807340..0000000000 --- a/src/react/context/image-manifest.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type * as React from "react"; -import { renderToString } from "react-dom/server"; -import { assertStringIncludes, assertThrows } from "#veryfront/testing/assert.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; -import type { OptimizedImageManifestSnapshot, OptimizedImageMetadata } from "#veryfront/types"; -import { ImageManifestProvider, useOptimizedImageMetadata } from "./index.tsx"; - -const metadata: OptimizedImageMetadata = { - original: "images/photo.jpg", - originalSize: 100, - variants: [{ - format: "webp", - size: 320, - width: 320, - height: 240, - path: "images/photo-320w-q73.webp", - fileSize: 42, - quality: 73, - }], - defaultFormat: "webp", - aspectRatio: 4 / 3, - engineIdentity: "test-engine@1", - quality: 73, -}; - -const manifest: OptimizedImageManifestSnapshot = { - identity: "a".repeat(64), - entries: { "images/photo.jpg": metadata }, -}; - -function MetadataProbe(): React.ReactElement { - const resolved = useOptimizedImageMetadata("/images/photo.jpg"); - return {resolved.variants[0]?.path}; -} - -describe("ImageManifestProvider", () => { - it("resolves validated metadata through the public context barrel", () => { - const html = renderToString( - - - , - ); - assertStringIncludes(html, "images/photo-320w-q73.webp"); - }); - - it("fails closed when metadata is requested without a render manifest", () => { - assertThrows( - () => renderToString(), - TypeError, - "requires ImageManifestProvider", - ); - }); - - it("rejects explicit metadata that disagrees with the build manifest", () => { - function MismatchProbe(): React.ReactElement { - useOptimizedImageMetadata("/images/photo.jpg", { - ...metadata, - quality: 74, - variants: metadata.variants.map((variant) => ({ - ...variant, - quality: 74, - })), - }); - return unreachable; - } - - assertThrows( - () => - renderToString( - - - , - ), - TypeError, - "does not match build manifest", - ); - }); -}); diff --git a/src/react/context/index.tsx b/src/react/context/index.tsx index f3ac295a69..219487c90f 100644 --- a/src/react/context/index.tsx +++ b/src/react/context/index.tsx @@ -7,15 +7,5 @@ * import { PageContextProvider, usePageContext } from "veryfront/context"; * ``` */ -export { - ImageManifestProvider, - PageContextProvider, - useOptimizedImageMetadata, - usePageContext, -} from "../runtime/core.ts"; -export type { - ImageManifestProviderProps, - MdxHeading, - PageContextProviderProps, - PageContextValue, -} from "../runtime/core.ts"; +export { PageContextProvider, usePageContext } from "../runtime/core.ts"; +export type { MdxHeading, PageContextProviderProps, PageContextValue } from "../runtime/core.ts"; diff --git a/src/react/index.ts b/src/react/index.ts index f470246843..d750b1f1f3 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -88,12 +88,9 @@ export { } from "./head-collector.ts"; export { - ImageManifestProvider, - type ImageManifestProviderProps, type MdxHeading, PageContextProvider, type PageContextValue, - useOptimizedImageMetadata, usePageContext, } from "./context/index.tsx"; diff --git a/src/react/public.ts b/src/react/public.ts index 05b09b5ba4..4d46f825ff 100644 --- a/src/react/public.ts +++ b/src/react/public.ts @@ -31,18 +31,8 @@ export type { LinkProps, RouterProviderProps, RouterValue } from "./router/index // --------------------------------------------------------------------------- // Context (veryfront/context) // --------------------------------------------------------------------------- -export { - ImageManifestProvider, - PageContextProvider, - useOptimizedImageMetadata, - usePageContext, -} from "./context/index.tsx"; -export type { - ImageManifestProviderProps, - MdxHeading, - PageContextProviderProps, - PageContextValue, -} from "./context/index.tsx"; +export { PageContextProvider, usePageContext } from "./context/index.tsx"; +export type { MdxHeading, PageContextProviderProps, PageContextValue } from "./context/index.tsx"; // --------------------------------------------------------------------------- // Fonts (veryfront/fonts) diff --git a/src/react/runtime/core.ts b/src/react/runtime/core.ts index 4e8370c4ff..0f561e08f6 100644 --- a/src/react/runtime/core.ts +++ b/src/react/runtime/core.ts @@ -1,9 +1,4 @@ import React, { useEffect } from "react"; -import type { - OptimizedImageManifestRenderSession, - OptimizedImageManifestSnapshot, - OptimizedImageMetadata, -} from "#veryfront/types"; import { descriptorFromHeadProps, HEAD_REACT_OWNER_ATTRIBUTE, @@ -12,12 +7,6 @@ import { serializeManagedHeadPayload, } from "#veryfront/html/managed-head-protocol.ts"; import { getClientHeadManager, getManagedHeadNonce } from "#veryfront/html/client-head-manager.ts"; -import { - captureOptimizedImageManifestRenderSession, - normalizeOptimizedImageSourcePath, - snapshotOptimizedImageManifest, - snapshotOptimizedImageMetadata, -} from "#veryfront/utils/optimized-image-manifest.ts"; /** Router state exposed through `useRouter()`. */ export interface RouterValue { @@ -122,17 +111,6 @@ export interface PageContextProviderProps { pageContext?: PageContextSeed; } -interface ImageManifestContextValue { - readonly identity: string; - resolve(source: string): OptimizedImageMetadata; -} - -export interface ImageManifestProviderProps { - /** A server render session or the exact bounded subset serialized for hydration. */ - manifest: OptimizedImageManifestRenderSession | OptimizedImageManifestSnapshot; - children: React.ReactNode; -} - const defaultRouter: RouterValue = { domain: "", path: "/", @@ -160,7 +138,6 @@ const defaultPageContext: PageContextValue = { const ROUTER_CONTEXT_SYMBOL = Symbol.for("veryfront.react.router-context"); const PAGE_CONTEXT_SYMBOL = Symbol.for("veryfront.react.page-context"); -const IMAGE_MANIFEST_CONTEXT_SYMBOL = Symbol.for("veryfront.react.image-manifest-context"); const globalRouterContext = globalThis as typeof globalThis & { [ROUTER_CONTEXT_SYMBOL]?: React.Context; @@ -170,21 +147,12 @@ const globalPageContext = globalThis as typeof globalThis & { [PAGE_CONTEXT_SYMBOL]?: React.Context; }; -const globalImageManifestContext = globalThis as typeof globalThis & { - [IMAGE_MANIFEST_CONTEXT_SYMBOL]?: React.Context; -}; - const RouterContext = globalRouterContext[ROUTER_CONTEXT_SYMBOL] ?? (globalRouterContext[ROUTER_CONTEXT_SYMBOL] = React.createContext(defaultRouter)); const PageContextContext = globalPageContext[PAGE_CONTEXT_SYMBOL] ?? (globalPageContext[PAGE_CONTEXT_SYMBOL] = React.createContext(defaultPageContext)); -const ImageManifestContext = globalImageManifestContext[IMAGE_MANIFEST_CONTEXT_SYMBOL] ?? - (globalImageManifestContext[IMAGE_MANIFEST_CONTEXT_SYMBOL] = React.createContext< - ImageManifestContextValue | null - >(null)); - type ClientHeadDescriptor = ManagedHeadDescriptor; function createClientHeadDescriptor( @@ -465,92 +433,6 @@ export function usePageContext(): PageContextValue { return React.useContext(PageContextContext); } -function imageManifestHasEntriesDataProperty(value: object): boolean { - let descriptor: PropertyDescriptor | undefined; - try { - descriptor = Object.getOwnPropertyDescriptor(value, "entries"); - } catch (cause) { - throw new TypeError("Optimized image manifest provider input could not be inspected", { - cause, - }); - } - return descriptor !== undefined && "value" in descriptor; -} - -function imageManifestContextFromSnapshot(value: unknown): ImageManifestContextValue { - const snapshot = snapshotOptimizedImageManifest(value); - return Object.freeze({ - identity: snapshot.identity, - resolve(source: string): OptimizedImageMetadata { - const key = normalizeOptimizedImageSourcePath(source); - const metadata = snapshot.entries[key]; - if (!metadata) { - throw new TypeError( - `Optimized image manifest has no entry for ${JSON.stringify(key)}`, - ); - } - return metadata; - }, - }); -} - -function captureImageManifestProviderValue(value: unknown): ImageManifestContextValue { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new TypeError("Optimized image manifest provider input must be an object"); - } - if (imageManifestHasEntriesDataProperty(value)) { - return imageManifestContextFromSnapshot(value); - } - const session = captureOptimizedImageManifestRenderSession(value); - return Object.freeze({ - identity: session.identity, - resolve: session.resolve, - }); -} - -/** Provide one immutable build manifest to an individual render or hydration tree. */ -export function ImageManifestProvider({ - manifest, - children, -}: ImageManifestProviderProps): React.ReactElement { - const value = React.useMemo(() => captureImageManifestProviderValue(manifest), [manifest]); - return React.createElement(ImageManifestContext.Provider, { value }, children); -} - -function optimizedImageMetadataEquals( - left: OptimizedImageMetadata, - right: OptimizedImageMetadata, -): boolean { - return JSON.stringify(left) === JSON.stringify(right); -} - -/** Resolve exact generated metadata and fail closed when the render has no matching entry. */ -export function useOptimizedImageMetadata( - source: string, - explicitMetadata?: OptimizedImageMetadata, -): OptimizedImageMetadata { - const context = React.useContext(ImageManifestContext); - const expectedOriginal = normalizeOptimizedImageSourcePath(source); - const explicit = explicitMetadata === undefined - ? undefined - : snapshotOptimizedImageMetadata(explicitMetadata, expectedOriginal); - - if (!context) { - if (explicit) return explicit; - throw new TypeError( - "Optimized image metadata requires ImageManifestProvider or an explicit metadata prop", - ); - } - - const resolved = context.resolve(source); - if (explicit && !optimizedImageMetadataEquals(explicit, resolved)) { - throw new TypeError( - `Explicit optimized image metadata does not match build manifest ${context.identity}`, - ); - } - return explicit ?? resolved; -} - /** * Flattens `Head` children into host elements, unwrapping React fragments so * `<>…` behaves like direct children — matching React's diff --git a/src/server/handlers/dev/framework-candidates.generated.ts b/src/server/handlers/dev/framework-candidates.generated.ts index c54915a3ea..4333d5c5e5 100644 --- a/src/server/handlers/dev/framework-candidates.generated.ts +++ b/src/server/handlers/dev/framework-candidates.generated.ts @@ -62,6 +62,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "!canOpen())", "!canRename)", "!cancelled", + "!candidate.matches(", "!cardVisible", "!child)", "!complete", @@ -97,6 +98,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "!dirty)", "!disabled", "!disabled)", + "!dismiss)", "!doc", "!document?.body)", "!e.defaultPrevented", @@ -109,6 +111,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "!el)", "!element", "!element)", + "!element.matches(", + "!element?.isConnected)", "!entry)", "!error)", "!event)", @@ -151,7 +155,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "!initial", "!initialFocus)", "!initialized)", - "!interactions.open", "!isAttachControlled;", "!isBoundedString(name,", "!isBoundedString(rawType,", @@ -175,6 +178,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "!isError", "!isExpanded)", "!isExpanded;", + "!isHiddenOrInert(candidate)", "!isHiddenOrInert(element)", "!isIdPending(candidate,", "!isLoading;", @@ -292,9 +296,12 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "!sink)", "!source)", "!state", + "!state)", "!state.disabled", "!state.dismissed", "!storageKey", + "!submenu?.contains(document?.activeElement", + "!summary?.contains(element))", "!surface)", "!text", "!text)", @@ -303,6 +310,9 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "!thinking)", "!timestamp)", "!tool.errorText)", + "!top", + "!top)", + "!topElement)", "!trigger)", "!trigger.disabled)", "!triggerElement", @@ -1542,6 +1552,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "Bind", "Blob([md],", "BlobStorage", + "Block", "Blurb", "Body", "Body:", @@ -1687,7 +1698,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "Called", "Cancel", "Canonical", - "Capturing", "Card", "Card(", "Card,", @@ -2540,6 +2550,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "DEFAULT_SIDE_OFFSET_PX,", "DEFAULT_SIDE_OFFSET_PX;", "DELETE", + "DOCUMENT_POSITION_FOLLOWING", "DOM", "DOM.", "DOMRect,", @@ -2643,6 +2654,10 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "DisclosureOptions", "DisclosureOptions)", "DisclosureOptions,", + "DismissableLayer", + "DismissableLayer[]", + "DismissableLayer[]):", + "DismissableLayer[];", "Dismisses", "Display", "Displays", @@ -2653,6 +2668,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "Document)", "Document):", "Document,", + "DocumentLayers", + "DocumentLayers>();", "DocumentScrollLock", "DocumentScrollLock>();", "Dot", @@ -2937,6 +2954,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "HTMLElement[]", "HTMLElement[]>();", "HTMLInputElement", + "HTMLInputElement;", "HTMLTextAreaElement", "HTMLTextAreaElement,", "HTMLTextAreaElement>)", @@ -2971,6 +2989,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "INFO_ICON_ELEMENTS:", "INLINE_ATTACHMENT_MAX_BYTES", "INLINE_ATTACHMENT_MAX_BYTES)", + "INPUT", "Icon", "Icon-only", "Icon:", @@ -3697,6 +3716,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "MutationObserverCtor", "MutationObserverCtor(updateDisabled)", "N", + "NATIVELY_DISABLEABLE_ELEMENTS", + "NATIVELY_DISABLEABLE_ELEMENTS.has(child.type)", "NFKC", "NO", "NOT", @@ -3845,6 +3866,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "Our", "Output", "Output:", + "Overlay", "Override", "Overrides", "Owns", @@ -3880,6 +3902,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "PaperclipIcon,", "Parameters", "Parent", + "ParentNode;", "Parse", "Parsed", "Part", @@ -4106,6 +4129,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "Re-fetch", "Re-measure", "React", + "React's", "React.AnchorHTMLAttributes", "React.ButtonHTMLAttributes", "React.ButtonHTMLAttributes,", @@ -4126,6 +4150,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "React.ComponentType;", "React.Context;", + "React.Dispatch>;", "React.Dispatch>;", "React.DragEvent)", @@ -4154,8 +4179,10 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "React.InputHTMLAttributes,", "React.KeyboardEvent):", + "React.KeyboardEvent,", "React.KeyboardEvent):", "React.KeyboardEvent,", @@ -4203,6 +4230,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "React.RefObject}", "React.RefObject)}", "React.cloneElement(", "React.cloneElement(child,", @@ -4272,7 +4300,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "React.useEffect;", "React.useId();", "React.useLayoutEffect", - "React.useLayoutEffect(()", "React.useLayoutEffect;", "React.useMemo(", "React.useMemo(()", @@ -4452,6 +4479,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "Regenerate", "Regenerate:", "RegenerateAction:", + "Register", "Registry", "Reject", "Relative", @@ -5519,6 +5547,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "[...previous];", "[...registry.values()].find((registered)", "[...restored.failures];", + "[...root.querySelectorAll('input[type=", "[...scope.mutationTails.values()]", "[...summaries.filter((s)", "[...trackedRef.current,", @@ -5574,6 +5603,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "[agent],", "[agents,", "[agents]);", + "[anchorElement,", "[answerParts],", "[api,", "[attachChildRef,", @@ -5584,6 +5614,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "[base];", "[beginPending,", "[canOpen,", + "[cancelClose,", "[cancelClose]);", "[cancelOperation,", "[cancelOperation]);", @@ -5601,6 +5632,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "[clearTypeahead]);", "[clipboard.copy,", "[closeIfInactive]);", + "[closeNow]);", "[code,", "[committedResetEpoch,", "[committedResetEpoch]);", @@ -5643,6 +5675,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "[descriptionPresent,", "[diagram,", "[disabled,", + "[dismiss,", "[doCopy,", "[dynamic]}", "[editValue,", @@ -5676,7 +5709,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "[index,", "[initialCache]", "[initialFocus,", - "[interactions.dismiss,", "[interactions.reset]);", "[interactions.setDisabled],", "[internal,", @@ -5750,6 +5782,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "[resolvedMode,", "[rightDesktop,", "[rightMobile,", + "[role=", "[role='menuitem']:not([aria-disabled='true'])", "[scope,", "[scopeMap]);", @@ -6140,6 +6173,11 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "anchor:", "anchor;", "anchor?.ownerDocument;", + "anchorElement", + "anchorElement,", + "anchorElement:", + "anchorElement={ctx.anchorElement}", + "anchorElement?:", "anchorRef", "anchorRef,", "anchorRef.current;", @@ -6233,6 +6271,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "approximate", "apps", "apps'", + "arbiter", "arbitrary", "arbitrary-value", "are", @@ -6250,11 +6289,13 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "aria-busy", "aria-busy=", "aria-busy={isLoading", + "aria-checked={checked}", "aria-controls", "aria-controls={context.listboxId}", "aria-controls={controlledListId}", "aria-controls={ctx.contentId}", "aria-controls={ctx.sidebarId(side)}", + "aria-controls={submenuId}", "aria-current", "aria-describedby", "aria-describedby={describedBy", @@ -6294,6 +6335,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "aria-labelledby={ariaLabelledBy}", "aria-labelledby={hasHeading", "aria-labelledby={labelledBy", + "aria-labelledby={triggerId}", "aria-live=", "aria-modal=", "aria-orientation=", @@ -6611,6 +6653,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "beginPending(scope,", "beginPending,", "behavior", + "behavior.", "behavior:", "behavior?:", "behavioral", @@ -6875,6 +6918,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "browser.", "browserConversationStoreLockRunner,", "browserConversationStoreLockRunner:", + "browsers", "browsing", "bubble", "bubble)", @@ -6983,6 +7027,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "call:", "callback", "callback,", + "callback-ref", "callback:", "callbackCause)", "callbacks", @@ -7034,12 +7079,16 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "candidate", "candidate);", "candidate,", + "candidate.checked)", + "candidate.form", "candidate.info.id);", "candidate.length", "candidate.mutationTails;", + "candidate.name", "candidate.store.load(id));", "candidate.store.save(conversation)),", "candidate.store.save(next));", + "candidate.tabIndex", "candidate:", "candidate;", "candidates", @@ -7214,7 +7263,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "checked:bg-[var(--primary)]", "checked:border-[var(--primary)]", "checked:shadow-[inset_0_0_0_4px_var(--secondary)]", - "checked={checked}", "checked={settings.autoFixErrors}", "checked={settings.autoSubmit}", "checking", @@ -7440,20 +7488,27 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "clone", "close", "close,", + "close-and-advance", "close.", "close:", + "closeAndRestoreFocus", + "closeAndRestoreFocus();", "closeIfInactive", "closeIfInactive();", + "closeNow", + "closeNow();", "closeTimer", "closeTimer.current", "closeTimer.current)", "closed", "closed/SSR", + "closedDetails", + "closedDetails)", + "closedDetails.querySelector(", "closes", "closes.", "closest()", "closest(UI_SCOPE_SELECTOR)", - "closing", "closure;", "cloud", "clsx", @@ -7728,6 +7783,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "container,", "container-query", "container.", + "container.ownerDocument.activeElement", "container.ownerDocument.activeElement;", "container:", "container?:", @@ -7751,6 +7807,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "content.", "content.offsetHeight,", "content.offsetWidth,", + "content.ownerDocument,", "content.scrollHeight;", "content.trim();", "content:", @@ -7849,6 +7906,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "context?.clearActiveConversationError", "context?.clearError)?.();", "context?.dismiss();", + "context?.dismiss;", "context?.endFocus();", "context?.endHover();", "context?.generatedContentId", @@ -8147,6 +8205,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "ctx.registerList(id,", "ctx.search.length", "ctx.setActiveId(id);", + "ctx.setAnchorElement(element);", + "ctx.setAnchorElement,", "ctx.setContentId((current)", "ctx.setContentId(resolvedId);", "ctx.setContentId,", @@ -8207,7 +8267,11 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "current.find((summary)", "current.generation", "current.id", + "current.layers.lastIndexOf(layer);", + "current.layers.length", + "current.layers.splice(index,", "current.left", + "current.onKeyDown);", "current.onSelect?.(current.value);", "current.parentElement)", "current.previousOverflow;", @@ -8664,6 +8728,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "detachOperationHandlers(operation:", "detached", "detail", + "details:not([open])", "determinate", "deterministic", "deterministic.", @@ -8729,7 +8794,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "disabled={!content.trim()}", "disabled={action.disabled}", "disabled={agent.disabled}", - "disabled={asChild", "disabled={c.isLoading", "disabled={c.isLoading}", "disabled={current", @@ -8755,6 +8819,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "dismiss", "dismiss,", "dismiss:", + "dismissable-layer.ts", "dismissal", "dismissal.", "dismissed", @@ -8803,6 +8868,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "docs)", "document", "document's", + "document,", "document.", "document.activeElement", "document.addEventListener(", @@ -8818,7 +8884,13 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "document.execCommand", "document.querySelector(", "document.removeEventListener(", + "document:", "document;", + "document?.getElementById(submenuId);", + "documentLayers", + "documentLayers.delete(document);", + "documentLayers.get(document);", + "documentLayers.set(document,", "documentScrollLocks", "documentScrollLocks.delete(ownerDocument);", "documentScrollLocks.get(ownerDocument);", @@ -9011,6 +9083,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "element.closest", "element.closest(", "element.contains(relatedTarget", + "element.contains(topElement))", "element.focus();", "element.focus({", "element.getAttribute(", @@ -9023,6 +9096,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "element.ref", "element.scrollIntoView({", "element.tabIndex", + "element.tagName", "element.type}-${index}", "element:", "element;", @@ -9046,6 +9120,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "emits", "emitted", "emitter),", + "emitting", "empty", "empty,", "empty-state", @@ -9111,7 +9186,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "entered", "entering", "enters", - "entire", "entirely", "entirely.", "entities:", @@ -9204,6 +9278,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "escapes.", "eslint-disable-next-line", "establish", + "established", "establisher", "establishes", "estimatedBytes:", @@ -9228,6 +9303,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "event.currentTarget.ownerDocument;", "event.currentTarget;", "event.defaultPrevented", + "event.isComposing", "event.key", "event.key)", "event.key.length", @@ -9450,6 +9526,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "fieldId", "fields", "fields.", + "fieldset", "fight", "file", "file,", @@ -9500,6 +9577,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "filled", "fills", "filter", + "filter((candidate)", "filter((element)", "filter((item):", "filter((p):", @@ -9617,6 +9695,9 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "focusFirst(panel);", "focusFirst(surface);", "focusFirst,", + "focusOrFallback(element:", + "focusOrFallback(first,", + "focusOrFallback(last,", "focusTarget", "focusTarget.isConnected)", "focusTrigger", @@ -9625,12 +9706,11 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "focusTrigger:", "focusWithoutScroll", "focusWithoutScroll(container);", + "focusWithoutScroll(element);", "focusWithoutScroll(element:", "focusWithoutScroll(first);", "focusWithoutScroll(focusTarget);", - "focusWithoutScroll(getFocusableElements(container)[0]", "focusWithoutScroll(item);", - "focusWithoutScroll(last);", "focusWithoutScroll(next);", "focusWithoutScroll(restoreTarget);", "focusWithoutScroll(target);", @@ -9816,6 +9896,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "getFileTypeLabel(ext:", "getFocusableElements", "getFocusableElements(container);", + "getFocusableElements(container)[0];", "getFocusableElements(container:", "getFocusableElements(document.body).filter((element)", "getInitial(name)}", @@ -9869,6 +9950,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "globalMarkdownRendererContext[MARKDOWN_RENDERER_CONTEXT_SYMBOL]", "globalThis", "globalThis.crypto,", + "globalThis.document", "globalThis.document;", "globalThis.innerHeight;", "globalThis.innerWidth;", @@ -9910,6 +9992,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "group.file.size", "group.file.url", "group.file.url,", + "group.find((candidate)", "group.push({", "group.registerLabel(id,", "group.tool.toolCallId;", @@ -9929,6 +10012,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "groupPartsInOrder,", "groupProps", "groupProps}", + "group[0])", "grouped", "grouped-part", "grouping", @@ -10055,6 +10139,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "handleOpenChange,", "handleSelect", "handleSelect,", + "handleSubmenuKeyDown", "handleSubmit", "handleSubmit:", "handleSuggestionSelect", @@ -10334,6 +10419,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "id={id", "id={id}", "id={resolvedId}", + "id={submenuId}", + "id={triggerId}", "id?,", "id?.trim()", "id?:", @@ -10474,6 +10561,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "initialFocus", "initialFocus,", "initialFocus=", + "initialFocus='[role=", "initialFocus={initialFocus}", "initialFocus?:", "initialMessages", @@ -10517,11 +10605,16 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "input-style", "input-style)", "input.", + "input.form", + "input.getRootNode()", + "input.name", "input.trim(),", "input.tsx", + "input.type", "input:", "input:not([disabled]),", "input:not([disabled]):not([type='hidden'])", + "input;", "input={input}", "input={input}>", "input={tool.input}", @@ -10597,8 +10690,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "intentionally", "interaction", "interaction,", + "interaction.", "interactions", - "interactions.dismiss();", "interactions.dismiss,", "interactions.endFocus,", "interactions.endHover,", @@ -10718,7 +10811,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "isDisabled", "isDisabled)", "isDisabled,", - "isDisabled}", "isDragActive", "isDragActive,", "isDragActive:", @@ -10816,6 +10908,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "isStreaming={group.isStreaming}", "isStreaming={isStreaming}", "isStreaming?:", + "isTabStopRadio(element)", + "isTabStopRadio(element:", "isToolEvidencePart(part))", "isToolEvidencePart(part:", "isToolPart(part)", @@ -11155,7 +11249,11 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "latestChildRef.current", "layer", "layer.", + "layer.element();", "layer;", + "layers)", + "layers,", + "layers:", "layout", "layout,", "layout.", @@ -11513,6 +11611,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "marks", "markup", "markup).", + "markup.", "mask", "mask-image)", "masked,", @@ -11662,6 +11761,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "menuOpen],", "menuOpen}", "menuitem", + "menuitemcheckbox", "merely", "merge", "mergeAsChildProps(", @@ -11676,7 +11776,13 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "mergeThemes,", "mergeThemes(", "merged", + "merged.disabled", + "merged.disabled;", + "merged.onAuxClickCapture", + "merged.onClickCapture", + "merged.onKeyDownCapture", "merged.ref", + "merged.tabIndex", "merged:", "merged;", "mergedProps", @@ -11688,6 +11794,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "mergedRef;", "mergedRootRef", "mergedTextareaRef", + "merged[", "merged[propName]", "merges", "merging", @@ -11915,6 +12022,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "mounting,", "mounts", "mouse", + "mouse-downs", "mousedown", "move", "moveActive", @@ -12122,6 +12230,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "non-enumerable", "non-error", "non-interactive", + "non-native", "non-negative", "non-renderable", "non-scrolling", @@ -12194,6 +12303,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "null>).current", "null>,", "null>;", + "null>>;", "null}", "num", "num(u.inputTokens),", @@ -12257,6 +12367,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "omission", "omit", "omits", + "omitted", "omitted,", "omitted.", "on", @@ -12318,9 +12429,9 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onChange?.(e);", "onChange?.(event);", "onCheckedChange", + "onCheckedChange(!checked)}", "onCheckedChange,", "onCheckedChange:", - "onCheckedChange={onCheckedChange}", "onCheckedChange={settings.onAutoFixErrorsChange}", "onCheckedChange={settings.onAutoSubmitChange}", "onCheckedChange?.(e.currentTarget.checked);", @@ -12361,6 +12472,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onClick={onRegenerate}", "onClick={onRetry}>", "onClick={onSourceClick", + "onClick={openNow}", "onClick={this.reset}", "onClick={toggleMode}", "onClick={toggle}", @@ -12400,6 +12512,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onDismiss:", "onDismiss;", "onDismiss={()", + "onDismiss={closeNow}", "onDismissRef", "onDismissRef.current", "onDismissRef.current(", @@ -12441,6 +12554,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onErrorRef.current", "onErrorRef.current?.(contextLoadFailure.error);", "onErrorRef.current?.(nextError);", + "onEscape", + "onEscape:", "onFeedback", "onFeedback(messageId,", "onFeedback(value)}", @@ -12469,6 +12584,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onKeyDown={(e)", "onKeyDown={(event)", "onKeyDown={handleKeyDown}", + "onKeyDown={handleSubmenuKeyDown}", "onKeyDown={onKeyDown", "onKeyDown?.(e);", "onKeyDown?.(event);", @@ -12489,6 +12605,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onMouseDown,", "onMouseDown={(event)", "onMouseDown?.(event);", + "onMouseDownCapture={(event)", "onMouseEnter,", "onMouseEnter:", "onMouseEnter={()", @@ -12537,7 +12654,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onOpenChange],", "onPointer", "onPointer);", - "onPointerDownCapture={(event)", "onPosition({", "onPosition:", "onPrev", @@ -12725,7 +12841,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "openDialog();", "openDialog)", "openNow", - "openNow())}", + "openNow();", "openRef", "openRef.current", "openSafeUploadUrl", @@ -12772,6 +12888,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "opposite[side];", "opt", "opt-in:", + "optgroup", "optimistic", "optimistic)", "optimistic,", @@ -12868,7 +12985,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "outside", "outside*", "outside-click", - "outside-dismiss", "outside/Escape", "over", "overflow", @@ -12923,6 +13039,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "ownerDocument", "ownerDocument),", "ownerDocument);", + "ownerDocument,", "ownerDocument.activeElement", "ownerDocument.addEventListener(", "ownerDocument.baseURI);", @@ -13411,11 +13528,17 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "preset.", "preset:", "preset;", + "press.", "prev.filter((f)", "prev.some((item)", "prev;", "prev];", "prevent", + "preventDisabledActivation(event);", + "preventDisabledActivation(event:", + "preventDisabledActivation;", + "preventDisabledKeyboardActivation(event:", + "preventDisabledKeyboardActivation;", "preventScroll:", "prevention.", "prevents", @@ -13531,6 +13654,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "prop-drilling.", "prop.", "propName", + "propagation", "proper", "properties", "properties.", @@ -13962,6 +14086,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "ref-counted", "ref.", "ref.current", + "ref.current,", "ref.current;", "ref:", "ref;", @@ -14025,6 +14150,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "registerContent(resolvedId);", "registerContent,", "registerContent:", + "registerDismissableLayer", + "registerDismissableLayer(", "registerItem", "registerItem,", "registerItem:", @@ -14225,6 +14352,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "replace(/:", "replaced", "replacement", + "replacement.", "replacement:", "replaces", "replacing", @@ -14352,6 +14480,9 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "resolvedAgentId", "resolvedAgentId:", "resolvedAgentId;", + "resolvedAnchor", + "resolvedAnchor,", + "resolvedAnchor;", "resolvedDescribedBy", "resolvedDescribedBy,", "resolvedDescribedBy;", @@ -14514,6 +14645,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "root's", "root.", "root.classList.toggle(", + "root.querySelectorAll", "root.setAttribute(", "root.style.colorScheme", "root;", @@ -14565,6 +14697,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "row:", "row;", "rowRef", + "rowRef.current?.ownerDocument;", "rows", "rows)", "rows).", @@ -14663,7 +14796,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "saves)", "scale:", "scheduleClose", - "scheduleClose()", "scheduleSave", "scheduleSave(candidate,", "scheduleSave(scope,", @@ -14912,6 +15044,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "server", "server-issued", "server-rendered", + "server-rendering", "server-side", "server.", "server/cache", @@ -14954,6 +15087,9 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "setActiveId:", "setActive]", "setAgentId]", + "setAnchorElement,", + "setAnchorElement:", + "setAnchorElement]", "setCardRef", "setCardRef(node);", "setCardRef,", @@ -15094,7 +15230,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "setOpen((v)", "setOpen(false),", "setOpen(false);", - "setOpen(false)}", "setOpen(nextOpen);", "setOpen(side,", "setOpen(true);", @@ -15608,6 +15743,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "state.focused)", "state.focused);", "state.hovered", + "state.layers.push(layer);", "state.maxBytes", "state.maxBytes}", "state.nodes", @@ -15826,6 +15962,9 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "submenu),", "submenu);", "submenu.", + "submenuId", + "submenuId]);", + "submenuId}-trigger", "submit", "submit,", "submit.", @@ -16427,9 +16566,17 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "top-full", "top-level", "top.", + "top.onEscape(event);", "top:", "top;", + "topElement", + "topElement.compareDocumentPosition(element)", + "topElement.contains(element))", + "topElement:", "topFaded", + "topmost", + "topmostLayer(layers);", + "topmostLayer(layers:", "total", "total,", "total:", @@ -16491,6 +16638,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "transitions.", "translate-x-0.5", "translate-x-1/2", + "translate-x-[18px]", "translate-y-1/2", "translateX(-100%)", "translateX(0)", @@ -16539,6 +16687,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "triggerElement.ownerDocument;", "triggerElement:", "triggerElement]);", + "triggerId", "triggerId,", "triggerId:", "triggerIndex", @@ -16670,6 +16819,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "ui/design-tokens.ts", "ui/dropdown-menu.tsx", "ui/floating.tsx", + "ui/focus-management.ts", "ui/icon-button.tsx", "ui/icons/index.ts", "ui/index.ts", @@ -16680,7 +16830,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "ui/skeleton.tsx", "ui/slot.tsx", "ui/status.tsx", - "ui/switch.tsx", "ui/tokens", "ui/tokens.tsx", "unambiguous.", @@ -16767,6 +16916,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "unregister", "unregister,", "unregister:", + "unregisterDismissableLayer", + "unregisterDismissableLayer();", "unregisterItem", "unregisterItem,", "unregisterItem:", @@ -16905,6 +17056,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "use", "use-composer-value.ts", "use-conversations.ts", + "use-isomorphic-layout-effect.ts", "use.", "use;", "useAgentCard", @@ -17395,6 +17547,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "visit", "visit((child.props", "visit(node);", + "visually", "vocabulary", "vocabulary.", "vocabulary;", @@ -17446,6 +17599,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "waits", "want", "warnedMissingAnchor", + "warning", "warning:", "warns", "was", diff --git a/src/types/index.ts b/src/types/index.ts index 237870d001..c41ad0c1c1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -77,44 +77,6 @@ export type { export type ComponentProps = Record; -/** Raster formats emitted by the optimized-image pipeline. */ -export type OptimizedImageFormat = "webp" | "avif" | "jpeg" | "png"; - -/** One concrete encoded image emitted by the build. */ -export interface ImageVariant { - format: OptimizedImageFormat; - size: number; - width: number; - height: number; - path: string; - fileSize: number; - quality: number; -} - -/** Build metadata for one source image. */ -export interface OptimizedImageMetadata { - original: string; - originalSize: number; - variants: ImageVariant[]; - defaultFormat: OptimizedImageFormat; - aspectRatio: number; - engineIdentity: string; - quality: number; -} - -/** Immutable optimized-image manifest payload, bounded for its transfer boundary. */ -export interface OptimizedImageManifestSnapshot { - identity: string; - entries: Record; -} - -/** Request-scoped optimized-image lookup used by server rendering. */ -export interface OptimizedImageManifestRenderSession { - readonly identity: string; - resolve(source: string): OptimizedImageMetadata; - snapshotReferenced(): OptimizedImageManifestSnapshot; -} - export type Component = React.ComponentType; export type ComponentFunction = ( @@ -230,8 +192,6 @@ export interface RenderResult { type: "mdx" | "component"; }; ssrHash?: string; - /** Exact optimized-image entries referenced while rendering this route. */ - imageManifest?: OptimizedImageManifestSnapshot; } export type { diff --git a/src/utils/optimized-image-manifest.ts b/src/utils/optimized-image-manifest.ts deleted file mode 100644 index f1cf82aafa..0000000000 --- a/src/utils/optimized-image-manifest.ts +++ /dev/null @@ -1,414 +0,0 @@ -import type { - ImageVariant, - OptimizedImageFormat, - OptimizedImageManifestRenderSession, - OptimizedImageManifestSnapshot, - OptimizedImageMetadata, -} from "#veryfront/types"; -import { IMAGE_OPTIMIZATION } from "./constants/build.ts"; -import { MAX_PATH_LENGTH_CHARS } from "./constants/limits.ts"; - -export const MAX_OPTIMIZED_IMAGE_REFERENCES_PER_RENDER = 256; -export const MAX_OPTIMIZED_IMAGE_HYDRATION_BYTES = 512 * 1024; -export const OPTIMIZED_IMAGE_MANIFEST_IDENTITY_PATTERN = /^[0-9a-f]{64}$/; - -const FORMATS = new Set([ - "webp", - "avif", - "jpeg", - "png", -]); -const METADATA_PROPERTIES = new Set([ - "original", - "originalSize", - "variants", - "defaultFormat", - "aspectRatio", - "engineIdentity", - "quality", -]); -const VARIANT_PROPERTIES = new Set([ - "format", - "size", - "width", - "height", - "path", - "fileSize", - "quality", -]); -const SNAPSHOT_PROPERTIES = new Set(["identity", "entries"]); -const encoder = new TextEncoder(); - -function hasControlCharacters(value: string): boolean { - return /\p{Cc}/u.test(value); -} - -function descriptors(value: object, label: string): PropertyDescriptorMap { - try { - return Object.getOwnPropertyDescriptors(value); - } catch (cause) { - throw new TypeError(`${label} could not be inspected`, { cause }); - } -} - -function exactDataProperties( - value: object, - expected: ReadonlySet, - label: string, -): PropertyDescriptorMap { - const result = descriptors(value, label); - const keys = Reflect.ownKeys(result); - if ( - keys.length !== expected.size || - keys.some((key) => !expected.has(key)) || - keys.some((key) => !("value" in result[key]!)) - ) { - throw new TypeError(`${label} has an invalid property shape`); - } - return result; -} - -function dataValue( - source: PropertyDescriptorMap, - key: string, - label: string, -): unknown { - const descriptor = source[key]; - if (!descriptor || !("value" in descriptor)) { - throw new TypeError(`${label}.${key} must be a data property`); - } - return descriptor.value; -} - -function positiveInteger(value: unknown, maximum: number, label: string): number { - if (!Number.isSafeInteger(value) || (value as number) <= 0 || (value as number) > maximum) { - throw new TypeError(`${label} is outside its supported range`); - } - return value as number; -} - -function imageFormat(value: unknown, label: string): OptimizedImageFormat { - if (typeof value !== "string" || !FORMATS.has(value as OptimizedImageFormat)) { - throw new TypeError(`${label} is not a supported image format`); - } - return value as OptimizedImageFormat; -} - -/** Convert an image component source URL into its exact manifest key. */ -export function normalizeOptimizedImageSourcePath(source: unknown): string { - if ( - typeof source !== "string" || - source.length === 0 || - source.length > MAX_PATH_LENGTH_CHARS * 3 || - hasControlCharacters(source) - ) { - throw new TypeError("Optimized image source must be a bounded path"); - } - const path = source.split(/[?#]/, 1)[0] ?? ""; - if (/^[A-Za-z][A-Za-z\d+.-]*:/.test(path) || path.startsWith("//")) { - throw new TypeError("Optimized image source must be a local path"); - } - const encodedSegments = path.replace(/^\/+/, "").split("/"); - const decodedSegments = encodedSegments.map((segment) => { - let decoded: string; - try { - decoded = decodeURIComponent(segment).normalize("NFC"); - } catch (cause) { - throw new TypeError("Optimized image source contains invalid encoding", { - cause, - }); - } - if (decoded.includes("/") || decoded.includes("\\")) { - throw new TypeError("Optimized image source contains an unsafe segment"); - } - return decoded; - }); - return canonicalOptimizedImageManifestPath(decodedSegments.join("/")); -} - -/** Validate one unescaped, project-relative image-manifest path. */ -export function canonicalOptimizedImageManifestPath(value: unknown): string { - if ( - typeof value !== "string" || - value.length === 0 || - value.length > MAX_PATH_LENGTH_CHARS || - value !== value.normalize("NFC") || - value.startsWith("/") || - value.startsWith("\\") || - /^[A-Za-z]:[\\/]/.test(value) || - value.includes("\\") || - hasControlCharacters(value) || - value.split("/").some((segment) => segment.length === 0 || segment === "." || segment === "..") - ) { - throw new TypeError("Optimized image manifest path is not canonical"); - } - const result = value; - if (result.length === 0 || result.length > MAX_PATH_LENGTH_CHARS) { - throw new TypeError("Optimized image manifest path is outside its supported length"); - } - return result; -} - -function snapshotVariant( - value: unknown, - quality: number, -): ImageVariant { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new TypeError("Optimized image variant must be an object"); - } - const source = exactDataProperties( - value, - VARIANT_PROPERTIES, - "Optimized image variant", - ); - const format = imageFormat( - dataValue(source, "format", "Optimized image variant"), - "Optimized image variant format", - ); - const size = positiveInteger( - dataValue(source, "size", "Optimized image variant"), - IMAGE_OPTIMIZATION.MAX_DIMENSION, - "Optimized image variant size", - ); - const width = positiveInteger( - dataValue(source, "width", "Optimized image variant"), - IMAGE_OPTIMIZATION.MAX_DIMENSION, - "Optimized image variant width", - ); - const height = positiveInteger( - dataValue(source, "height", "Optimized image variant"), - IMAGE_OPTIMIZATION.MAX_DIMENSION, - "Optimized image variant height", - ); - const path = canonicalOptimizedImageManifestPath( - dataValue(source, "path", "Optimized image variant"), - ); - const fileSize = positiveInteger( - dataValue(source, "fileSize", "Optimized image variant"), - Number.MAX_SAFE_INTEGER, - "Optimized image variant fileSize", - ); - const variantQuality = dataValue(source, "quality", "Optimized image variant"); - if (variantQuality !== quality || size !== width) { - throw new TypeError("Optimized image variant does not match its metadata"); - } - return Object.freeze({ - format, - size, - width, - height, - path, - fileSize, - quality, - }); -} - -/** Validate and detach one manifest entry. */ -export function snapshotOptimizedImageMetadata( - value: unknown, - expectedOriginal?: string, -): OptimizedImageMetadata { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new TypeError("Optimized image metadata must be an object"); - } - const source = exactDataProperties( - value, - METADATA_PROPERTIES, - "Optimized image metadata", - ); - const original = canonicalOptimizedImageManifestPath( - dataValue(source, "original", "Optimized image metadata"), - ); - if (expectedOriginal !== undefined && original !== expectedOriginal) { - throw new TypeError("Optimized image metadata does not match its manifest key"); - } - const originalSize = positiveInteger( - dataValue(source, "originalSize", "Optimized image metadata"), - Number.MAX_SAFE_INTEGER, - "Optimized image originalSize", - ); - const defaultFormat = imageFormat( - dataValue(source, "defaultFormat", "Optimized image metadata"), - "Optimized image default format", - ); - const aspectRatio = dataValue(source, "aspectRatio", "Optimized image metadata"); - if (typeof aspectRatio !== "number" || !Number.isFinite(aspectRatio) || aspectRatio <= 0) { - throw new TypeError("Optimized image aspect ratio must be positive and finite"); - } - const engineIdentity = dataValue(source, "engineIdentity", "Optimized image metadata"); - if ( - typeof engineIdentity !== "string" || - engineIdentity.length === 0 || - engineIdentity.length > IMAGE_OPTIMIZATION.MAX_ENGINE_IDENTITY_CHARACTERS || - engineIdentity.trim() !== engineIdentity || - engineIdentity.normalize("NFC") !== engineIdentity || - hasControlCharacters(engineIdentity) - ) { - throw new TypeError("Optimized image engine identity is invalid"); - } - const quality = dataValue(source, "quality", "Optimized image metadata"); - if (!Number.isInteger(quality) || (quality as number) < 1 || (quality as number) > 100) { - throw new TypeError("Optimized image quality must be an integer from 1 through 100"); - } - const variantsValue = dataValue(source, "variants", "Optimized image metadata"); - if ( - !Array.isArray(variantsValue) || - variantsValue.length === 0 || - variantsValue.length > IMAGE_OPTIMIZATION.MAX_OUTPUT_SIZES * FORMATS.size - ) { - throw new TypeError("Optimized image variants are outside their supported count"); - } - const variants = variantsValue.map((variant) => snapshotVariant(variant, quality as number)); - const identities = new Set(); - const paths = new Set(); - let hasDefault = false; - for (const variant of variants) { - const identity = `${variant.format}\0${variant.width}`; - const path = variant.path.toLowerCase(); - if (identities.has(identity) || paths.has(path)) { - throw new TypeError("Optimized image variants must be unique"); - } - identities.add(identity); - paths.add(path); - if (variant.format === defaultFormat) hasDefault = true; - } - if (!hasDefault) { - throw new TypeError("Optimized image metadata has no default-format variant"); - } - return Object.freeze({ - original, - originalSize, - variants: Object.freeze(variants) as unknown as ImageVariant[], - defaultFormat, - aspectRatio, - engineIdentity, - quality: quality as number, - }); -} - -/** Validate, detach, bound, and freeze a per-render manifest subset. */ -export function snapshotOptimizedImageManifest( - value: unknown, - options: { - maxEntries?: number; - maxBytes?: number; - } = {}, -): OptimizedImageManifestSnapshot { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new TypeError("Optimized image manifest snapshot must be an object"); - } - const source = exactDataProperties( - value, - SNAPSHOT_PROPERTIES, - "Optimized image manifest snapshot", - ); - const identity = dataValue(source, "identity", "Optimized image manifest snapshot"); - if ( - typeof identity !== "string" || - !OPTIMIZED_IMAGE_MANIFEST_IDENTITY_PATTERN.test(identity) - ) { - throw new TypeError("Optimized image manifest identity must be a SHA-256 digest"); - } - const entriesValue = dataValue(source, "entries", "Optimized image manifest snapshot"); - if (typeof entriesValue !== "object" || entriesValue === null || Array.isArray(entriesValue)) { - throw new TypeError("Optimized image manifest entries must be an object"); - } - const entryDescriptors = descriptors(entriesValue, "Optimized image manifest entries"); - const keys = Reflect.ownKeys(entryDescriptors); - const maxEntries = options.maxEntries ?? MAX_OPTIMIZED_IMAGE_REFERENCES_PER_RENDER; - if ( - keys.length > maxEntries || - keys.some((key) => typeof key !== "string") || - keys.some((key) => !("value" in entryDescriptors[key]!)) - ) { - throw new TypeError(`Optimized image manifest exceeds ${maxEntries} entries`); - } - const entries = Object.create(null) as Record; - for (const key of (keys as string[]).sort()) { - const canonicalKey = canonicalOptimizedImageManifestPath(key); - if (canonicalKey !== key) { - throw new TypeError("Optimized image manifest key is not canonical"); - } - Object.defineProperty(entries, key, { - configurable: false, - enumerable: true, - value: snapshotOptimizedImageMetadata( - (entryDescriptors[key] as PropertyDescriptor & { value: unknown }).value, - key, - ), - writable: false, - }); - } - Object.freeze(entries); - const snapshot = Object.freeze({ identity, entries }); - const bytes = encoder.encode(JSON.stringify(snapshot)).byteLength; - const maxBytes = options.maxBytes ?? MAX_OPTIMIZED_IMAGE_HYDRATION_BYTES; - if (bytes > maxBytes) { - throw new TypeError(`Optimized image manifest exceeds ${maxBytes} bytes`); - } - return snapshot; -} - -function captureMethod( - value: object, - name: string, - label: string, -): (...args: unknown[]) => unknown { - let method: unknown; - try { - method = Reflect.get(value, name); - } catch (cause) { - throw new TypeError(`${label}.${name} could not be read`, { cause }); - } - if (typeof method !== "function") { - throw new TypeError(`${label}.${name} must be a function`); - } - return (...args: unknown[]) => Reflect.apply(method, value, args); -} - -/** - * Detach a request-scoped manifest session from a caller-owned object. - * - * The captured methods retain their original receiver so later replacement of - * public properties cannot redirect lookups during an in-flight render. - */ -export function captureOptimizedImageManifestRenderSession( - value: unknown, -): OptimizedImageManifestRenderSession { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new TypeError("Optimized image manifest render session must be an object"); - } - let identity: unknown; - try { - identity = Reflect.get(value, "identity"); - } catch (cause) { - throw new TypeError("Optimized image manifest render session identity could not be read", { - cause, - }); - } - if ( - typeof identity !== "string" || - !OPTIMIZED_IMAGE_MANIFEST_IDENTITY_PATTERN.test(identity) - ) { - throw new TypeError("Optimized image manifest render session identity is invalid"); - } - const resolve = captureMethod( - value, - "resolve", - "Optimized image manifest render session", - ); - const snapshotReferenced = captureMethod( - value, - "snapshotReferenced", - "Optimized image manifest render session", - ); - return Object.freeze({ - identity, - resolve(source: string): OptimizedImageMetadata { - return snapshotOptimizedImageMetadata(resolve(source)); - }, - snapshotReferenced(): OptimizedImageManifestSnapshot { - return snapshotOptimizedImageManifest(snapshotReferenced()); - }, - }); -} diff --git a/tests/integration/runtime/react/config-generator.test.ts b/tests/integration/runtime/react/config-generator.test.ts index 5f676ece33..9906927723 100644 --- a/tests/integration/runtime/react/config-generator.test.ts +++ b/tests/integration/runtime/react/config-generator.test.ts @@ -1,4 +1,4 @@ -import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert"; +import { assert, assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert"; import { describe, it } from "#veryfront/testing/bdd"; import { createReactVersionSwitcher, @@ -56,7 +56,7 @@ describe("React Config Generator", () => { const config = await readJson(`${context.projectDir}/deno.react19.json`); assertEquals(typeof config.imports.react, "string"); - assertEquals(config.imports.react.includes("19.0.0"), true); + assertEquals(config.imports.react.includes(REACT_CONFIGS["19"].exact), true); }); }); @@ -122,7 +122,7 @@ describe("React Config Generator", () => { it("returns React 19 imports", () => { const imports = getReactImports("19"); assertEquals(typeof imports.react, "string"); - assert(imports.react?.includes("19.0.0")); + assert(imports.react?.includes(REACT_CONFIGS["19"].exact)); }); it("throws on invalid version", () => { @@ -191,12 +191,14 @@ describe("React Config Generator", () => { }); }); - it("handles malformed deno.json", async () => { + it("rejects malformed deno.json", async () => { await withTestContext("detect-malformed", async (context: TestContext) => { await writeTextFile(`${context.projectDir}/deno.json`, "invalid json {"); - const detected = await detectReactVersionFromConfig(context.projectDir); - assertEquals(detected, null); + await assertRejects( + () => detectReactVersionFromConfig(context.projectDir), + SyntaxError, + ); }); }); }); From 1b0e21d6eb41ef59d70b1103f37c03b699683810 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 2 Aug 2026 19:48:30 +0200 Subject: [PATCH 08/12] fix(react): preserve list activation provenance --- src/react/components/ui/list.test.tsx | 51 +++++++++++++++++++ src/react/components/ui/list.tsx | 26 ++++++++-- .../dev/framework-candidates.generated.ts | 20 ++++++-- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/react/components/ui/list.test.tsx b/src/react/components/ui/list.test.tsx index 25f7f7c20c..6c7821e14b 100644 --- a/src/react/components/ui/list.test.tsx +++ b/src/react/components/ui/list.test.tsx @@ -350,6 +350,57 @@ describe("ListItem", () => { } }); + it("retains primary-action provenance without Event.composedPath", () => { + const dom = createDom(); + const restore = installDom(dom); + const composedPathDescriptor = Object.getOwnPropertyDescriptor( + dom.window.Event.prototype, + "composedPath", + ); + Object.defineProperty(dom.window.Event.prototype, "composedPath", { + configurable: true, + value: undefined, + }); + const root = createRoot(document.getElementById("root")!); + let primaryActivations = 0; + let legacyRowClicks = 0; + + try { + flushSync(() => { + root.render( + { + primaryActivations += 1; + }} + onClick={() => { + legacyRowClicks += 1; + }} + />, + ); + }); + const primaryLabel = document.querySelector("button span"); + assert(primaryLabel); + + flushSync(() => primaryLabel.click()); + assertEquals(primaryActivations, 1); + assertEquals(legacyRowClicks, 0); + } finally { + flushSync(() => root.unmount()); + if (composedPathDescriptor) { + Object.defineProperty( + dom.window.Event.prototype, + "composedPath", + composedPathDescriptor, + ); + } else { + delete (dom.window.Event.prototype as unknown as Record) + .composedPath; + } + restore(); + } + }); + it("preserves React 19 callback-ref cleanup for the primary action", () => { const dom = createDom(); const restore = installDom(dom); diff --git a/src/react/components/ui/list.tsx b/src/react/components/ui/list.tsx index 52cf264ccc..5d15947f2c 100644 --- a/src/react/components/ui/list.tsx +++ b/src/react/components/ui/list.tsx @@ -20,6 +20,27 @@ import { cx as cn } from "./cva.ts"; // unmounts its button before the already-collected ancestor click runs. const primaryActionElements = new WeakSet(); +function eventCameFromPrimaryAction( + event: React.MouseEvent, +): boolean { + const composedPath = event.nativeEvent.composedPath; + if (typeof composedPath === "function") { + return composedPath.call(event.nativeEvent).some((target) => primaryActionElements.has(target)); + } + + // Older DOM implementations do not expose composedPath(). Retain the same + // provenance check by walking from the original target. This also works when + // the primary button synchronously unmounts before the row handler runs, + // because the detached target keeps its ancestry inside the detached button. + let target: EventTarget | null = event.target; + while (target) { + if (primaryActionElements.has(target)) return true; + target = (target as EventTarget & { parentNode?: EventTarget | null }) + .parentNode ?? null; + } + return false; +} + /** Props accepted by {@link List}. */ export interface ListProps extends React.HTMLAttributes { ref?: React.Ref; @@ -149,10 +170,7 @@ export function ListItem({ function handleClick(event: React.MouseEvent): void { const primaryActionElement = primaryActionElementRef.current; - const cameFromPrimaryAction = event.nativeEvent.composedPath().some((target) => - primaryActionElements.has(target) - ); - if (cameFromPrimaryAction) { + if (eventCameFromPrimaryAction(event)) { return; } if (onClick) onClick(event); diff --git a/src/server/handlers/dev/framework-candidates.generated.ts b/src/server/handlers/dev/framework-candidates.generated.ts index 4333d5c5e5..3763b042c3 100644 --- a/src/server/handlers/dev/framework-candidates.generated.ts +++ b/src/server/handlers/dev/framework-candidates.generated.ts @@ -4194,6 +4194,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "React.MouseEvent):", "React.MouseEvent,", "React.MouseEvent):", + "React.MouseEvent,", "React.MouseEvent)", "React.MouseEvent):", "React.MouseEvent,", @@ -4522,6 +4523,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "RestoreResult", "RestoreResult;", "Result", + "Retain", "Retained", "Retry", "Retry:", @@ -6156,6 +6158,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "anatomy;", "ancestor", "ancestors:", + "ancestry", "anchor", "anchor's", "anchor,", @@ -7044,8 +7047,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "callout,", "calls", "calls,", - "cameFromPrimaryAction", - "cameFromPrimaryAction)", "can", "can't", "can-hover:", @@ -7686,6 +7687,9 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "composed).", "composed,", "composed.", + "composedPath", + "composedPath().", + "composedPath.call(event.nativeEvent).some((target)", "composedRef", "composedRef,", "composedRef;", @@ -9310,7 +9314,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "event.key;", "event.keyCode", "event.metaKey", - "event.nativeEvent.composedPath().some((target)", + "event.nativeEvent.composedPath;", "event.nativeEvent.isComposing", "event.nativeEvent.keyCode", "event.nativeEvent;", @@ -9330,6 +9334,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "event?.defaultPrevented", "event?.preventDefault();", "event?:", + "eventCameFromPrimaryAction(", + "eventCameFromPrimaryAction(event))", "events", "ever", "every", @@ -13168,6 +13174,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "parent", "parent's", "parent-defined", + "parentNode", + "parentNode?:", "parity", "parity).", "parseChatUploadResponse(", @@ -13598,7 +13606,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "primaryActionElementRef.current;", "primaryActionElements", "primaryActionElements.add(node);", - "primaryActionElements.has(target)", + "primaryActionElements.has(target))", + "primaryActionElements.has(target));", "primaryActionProps", "primaryActionProps,", "primaryActionProps={{", @@ -14734,6 +14743,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "runner.", "running", "runs", + "runs,", "runs.", "runtime", "runtime.", @@ -16169,6 +16179,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "target", "target)", "target.", + "target:", "target=", "target?:", "tbody>", @@ -17597,6 +17608,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "wait", "waiting", "waits", + "walking", "want", "warnedMissingAnchor", "warning", From 291f2e9319c3e4c9df2355a1d0a4a1e0380ae293 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 2 Aug 2026 19:51:38 +0200 Subject: [PATCH 09/12] test(react): satisfy optimized image typecheck --- src/react/components/optimized-image/helpers.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/react/components/optimized-image/helpers.test.ts b/src/react/components/optimized-image/helpers.test.ts index f3a3ad07cf..26e8ab55cf 100644 --- a/src/react/components/optimized-image/helpers.test.ts +++ b/src/react/components/optimized-image/helpers.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { generateSrcSet, getImageExtension, getOptimizedPath } from "./helpers.ts"; @@ -30,12 +30,10 @@ describe("optimized-image helpers", () => { describe("generateSrcSet", () => { it("generates srcset string with multiple sizes", () => { const parts = generateSrcSet("/photo.png", "webp", [320, 640, 1024], 80).split(", "); - assertEquals(parts.length, 3); - - parts.forEach((part) => assertExists(part)); - assertEquals(parts[0].endsWith("320w"), true); - assertEquals(parts[1].endsWith("640w"), true); - assertEquals(parts[2].endsWith("1024w"), true); + assertEquals( + parts.map((part) => part.slice(part.lastIndexOf(" ") + 1)), + ["320w", "640w", "1024w"], + ); }); it("generates single-size srcset", () => { From 30927542bb4c617fa182a63eead08eed6125d916 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 2 Aug 2026 19:56:43 +0200 Subject: [PATCH 10/12] docs(react): avoid premature action authorization claim --- src/react/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/react/README.md b/src/react/README.md index 1da1a99695..13ee39fc2f 100644 --- a/src/react/README.md +++ b/src/react/README.md @@ -309,8 +309,7 @@ const response = buildResponse(html, { - All React 18 features - RSC (React Server Components) -- Server Actions with a required generation-owned authorization provider; see - [Authorize React Server Actions](../../docs/guides/extensions.md#authorize-react-server-actions) +- Server Actions - Improved Suspense - Enhanced streaming From 1a28359b37f7761ac6b7a6a98c0862810c88cb37 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 2 Aug 2026 20:06:25 +0200 Subject: [PATCH 11/12] fix(react): keep bootstrap scripts outside hydration root --- .../compat/ssr-adapter/html-wrapper.test.ts | 21 +++++++- src/react/compat/ssr-adapter/html-wrapper.ts | 10 +++- .../ssr-adapter/response-builder.test.ts | 54 ++++++++++++++++++- .../compat/ssr-adapter/response-builder.ts | 21 +++++--- src/react/compat/ssr-adapter/types.ts | 1 + .../components/optimized-image/utils.tsx | 8 +-- 6 files changed, 99 insertions(+), 16 deletions(-) diff --git a/src/react/compat/ssr-adapter/html-wrapper.test.ts b/src/react/compat/ssr-adapter/html-wrapper.test.ts index 7687d24028..8b677e455c 100644 --- a/src/react/compat/ssr-adapter/html-wrapper.test.ts +++ b/src/react/compat/ssr-adapter/html-wrapper.test.ts @@ -12,6 +12,7 @@ function createOptions(overrides: Partial = {}): HTMLWrapOption links: [], scripts: [], bootstrapScripts: [], + bootstrapModules: [], ...overrides, }; } @@ -111,6 +112,16 @@ describe("html-wrapper", () => { expect(result).toContain(''); }); + it("should include bootstrap modules in body", () => { + const result = wrapInHTML( + "
Test
", + createOptions({ bootstrapModules: ["/app.mjs", "/vendor.mjs"] }), + ); + + expect(result).toContain(''); + expect(result).toContain(''); + }); + it("should add nonce to head scripts when provided", () => { const result = wrapInHTML( "
Test
", @@ -182,6 +193,7 @@ describe("html-wrapper", () => { links: [{ rel: breakout, href: breakout }], scripts: [{ src: breakout, type: breakout }], bootstrapScripts: [breakout], + bootstrapModules: [breakout], nonce: breakout, }), ); @@ -190,7 +202,7 @@ describe("html-wrapper", () => { const { document } = dom.window; try { expect(document.title).toBe(title); - expect(document.querySelectorAll("script")).toHaveLength(2); + expect(document.querySelectorAll("script")).toHaveLength(3); expect(document.querySelector("[data-injected]")).toBeNull(); const meta = document.querySelectorAll("meta")[2]; @@ -201,12 +213,17 @@ describe("html-wrapper", () => { expect(link?.getAttribute("rel")).toBe(breakout); expect(link?.getAttribute("href")).toBe(breakout); - const [headScript, bootstrapScript] = [...document.querySelectorAll("script")]; + const [headScript, bootstrapScript, bootstrapModule] = [ + ...document.querySelectorAll("script"), + ]; expect(headScript?.getAttribute("src")).toBe(breakout); expect(headScript?.getAttribute("type")).toBe(breakout); expect(headScript?.getAttribute("nonce")).toBe(breakout); expect(bootstrapScript?.getAttribute("src")).toBe(breakout); expect(bootstrapScript?.getAttribute("nonce")).toBe(breakout); + expect(bootstrapModule?.getAttribute("src")).toBe(breakout); + expect(bootstrapModule?.getAttribute("type")).toBe("module"); + expect(bootstrapModule?.getAttribute("nonce")).toBe(breakout); } finally { dom.window.close(); } diff --git a/src/react/compat/ssr-adapter/html-wrapper.ts b/src/react/compat/ssr-adapter/html-wrapper.ts index 620197f3ec..117ff8fa6e 100644 --- a/src/react/compat/ssr-adapter/html-wrapper.ts +++ b/src/react/compat/ssr-adapter/html-wrapper.ts @@ -31,6 +31,14 @@ export function createHTMLShell(options: HTMLWrapOptions): HTMLShell { .map((src) => ``) .join("\n "); + const bootstrapModuleTags = (options.bootstrapModules ?? []) + .map((src) => + `` + ) + .join("\n "); + + const bootstrapTags = [bootstrapScriptTags, bootstrapModuleTags].filter(Boolean).join("\n "); + return { prefix: ` @@ -45,7 +53,7 @@ export function createHTMLShell(options: HTMLWrapOptions): HTMLShell {
`, suffix: `
- ${bootstrapScriptTags} + ${bootstrapTags} `, }; diff --git a/src/react/compat/ssr-adapter/response-builder.test.ts b/src/react/compat/ssr-adapter/response-builder.test.ts index 291153eaad..46cfe09d05 100644 --- a/src/react/compat/ssr-adapter/response-builder.test.ts +++ b/src/react/compat/ssr-adapter/response-builder.test.ts @@ -1,10 +1,15 @@ import { assertEquals, assertRejects, assertStringIncludes } from "#veryfront/testing/assert.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; -import { createSSRResponseFromResult } from "./response-builder.ts"; +import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; +import * as React from "react"; +import { createSSRResponse, createSSRResponseFromResult } from "./response-builder.ts"; +import type { ReactDOMServer } from "./server-loader.ts"; +import { __injectReactDOMServerForTests, resetReactCache } from "./server-loader.ts"; const encoder = new TextEncoder(); describe("createSSRResponse", () => { + afterEach(() => resetReactCache()); + it("wraps readable renderer output in a complete HTML document", async () => { const stream = new ReadableStream({ start(controller) { @@ -51,6 +56,51 @@ describe("createSSRResponse", () => { ); }); + it("keeps document bootstrap tags outside the hydration root", async () => { + let rendererOptions: + | Parameters>[1] + | undefined; + const componentStream = Object.assign( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("
rendered
")); + controller.close(); + }, + }), + { allReady: Promise.resolve() }, + ); + const server: ReactDOMServer = { + renderToString: () => "
rendered
", + renderToStaticMarkup: () => "
rendered
", + renderToReadableStream(_element, options) { + rendererOptions = options; + return Promise.resolve(componentStream); + }, + }; + __injectReactDOMServerForTests(server, "19.2.4"); + + const response = await createSSRResponse( + React.createElement("main", null, "rendered"), + { + reactVersion: "19.2.4", + bootstrapScripts: ["/app.js"], + bootstrapModules: ["/app.mjs"], + nonce: "response-nonce", + }, + ); + const html = await response.text(); + const rootEnd = html.indexOf("
"); + const scriptTag = ''; + const moduleTag = ''; + + assertEquals(html.split(scriptTag).length - 1, 1); + assertEquals(html.split(moduleTag).length - 1, 1); + assertEquals(html.indexOf(scriptTag) > rootEnd, true); + assertEquals(html.indexOf(moduleTag) > rootEnd, true); + assertEquals(rendererOptions?.bootstrapScripts, []); + assertEquals(rendererOptions?.bootstrapModules, []); + }); + it("fails closed when a renderer violates the result contract", async () => { await assertRejects( () => createSSRResponseFromResult({}, {}, "19.2.4"), diff --git a/src/react/compat/ssr-adapter/response-builder.ts b/src/react/compat/ssr-adapter/response-builder.ts index 6358f84ec1..22b1b15e08 100644 --- a/src/react/compat/ssr-adapter/response-builder.ts +++ b/src/react/compat/ssr-adapter/response-builder.ts @@ -20,10 +20,22 @@ function createWrapOptions(options: SSRResponseOptions): HTMLWrapOptions { links: options.links ?? [], scripts: options.scripts ?? [], bootstrapScripts: options.bootstrapScripts ?? [], + bootstrapModules: options.bootstrapModules ?? [], nonce: options.nonce, }; } +function createComponentRenderOptions(options: SSRResponseOptions): SSRResponseOptions { + return { + ...options, + // The response builder owns the surrounding document. Keep document-level + // bootstrap tags out of the component render so they cannot become children + // of the hydration root; createHTMLShell appends them after that root closes. + bootstrapScripts: [], + bootstrapModules: [], + }; +} + /** Wrap a component byte stream in one complete HTML document. */ export function wrapReadableStreamInHTML( source: ReadableStream, @@ -128,12 +140,7 @@ export async function createSSRResponseFromResult( if (result.stream || result.pipe) { const componentStream = result.stream ?? await pipeableToReadableStream(result); - // React's stream renderers already emit bootstrapScripts inside their - // component stream. The document shell must not append them a second time. - const body = wrapReadableStreamInHTML(componentStream, { - ...wrapOptions, - bootstrapScripts: [], - }); + const body = wrapReadableStreamInHTML(componentStream, wrapOptions); return new Response(body, { status: 200, headers }); } @@ -154,6 +161,6 @@ export async function createSSRResponse( options: SSRResponseOptions = {}, ): Promise { const version = options.reactVersion ?? getReactVersionInfo().version; - const result = await renderToStreamAdapter(element, options); + const result = await renderToStreamAdapter(element, createComponentRenderOptions(options)); return createSSRResponseFromResult(result, options, version); } diff --git a/src/react/compat/ssr-adapter/types.ts b/src/react/compat/ssr-adapter/types.ts index 08138ead45..9ccf04905e 100644 --- a/src/react/compat/ssr-adapter/types.ts +++ b/src/react/compat/ssr-adapter/types.ts @@ -37,5 +37,6 @@ export interface HTMLWrapOptions { links: Array<{ rel: string; href: string }>; scripts: Array<{ src: string; type?: string }>; bootstrapScripts: string[]; + bootstrapModules?: string[]; nonce?: string; } diff --git a/src/react/components/optimized-image/utils.tsx b/src/react/components/optimized-image/utils.tsx index d2d5a729a1..6ac5d3c947 100644 --- a/src/react/components/optimized-image/utils.tsx +++ b/src/react/components/optimized-image/utils.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import type { CSSProperties, ReactElement, ReactNode } from "react"; const MAX_BLUR_COLOR_CHARS = 1_024; @@ -94,10 +94,10 @@ export function ResponsiveImageContainer({ }: { width: number; height: number; - children: React.ReactNode; + children: ReactNode; className?: string; - style?: React.CSSProperties; -}): React.ReactElement { + style?: CSSProperties; +}): ReactElement { return (
Date: Sun, 2 Aug 2026 20:15:11 +0200 Subject: [PATCH 12/12] refactor(react): use stable chat module imports --- src/react/components/chat/chat/components/tool-ui.tsx | 2 +- src/react/primitives/tool-primitives.tsx | 2 +- src/server/handlers/dev/framework-candidates.generated.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/react/components/chat/chat/components/tool-ui.tsx b/src/react/components/chat/chat/components/tool-ui.tsx index 1e6a5ea658..e73b3c2787 100644 --- a/src/react/components/chat/chat/components/tool-ui.tsx +++ b/src/react/components/chat/chat/components/tool-ui.tsx @@ -16,7 +16,7 @@ import { import { Alert, AlertContent, AlertIcon } from "../../../ui/alert.tsx"; import { createStrictContext } from "../../../create-strict-context.ts"; import type { ChatDynamicToolPart, ChatToolPart } from "#veryfront/agent/react"; -import { type ChatJsonValue, toChatJsonValue } from "../../../../../chat/json-value.ts"; +import { type ChatJsonValue, toChatJsonValue } from "#veryfront/chat/json-value.ts"; import { escapeHtml } from "#veryfront/utils/html-escape.ts"; import { isSkillToolPart } from "../utils/message-parts.ts"; import { getSkillToolProps, SkillTool } from "./skill-tool.tsx"; diff --git a/src/react/primitives/tool-primitives.tsx b/src/react/primitives/tool-primitives.tsx index 07d7242633..b47ee21e26 100644 --- a/src/react/primitives/tool-primitives.tsx +++ b/src/react/primitives/tool-primitives.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import type { ChatDynamicToolPart, ChatToolPart, ChatToolState } from "#veryfront/agent/react"; -import { toChatJsonValue } from "../../chat/json-value.ts"; +import { toChatJsonValue } from "#veryfront/chat/json-value.ts"; const TOOL_VALUE_LIMITS = Object.freeze({ maxContainerEntries: 500, diff --git a/src/server/handlers/dev/framework-candidates.generated.ts b/src/server/handlers/dev/framework-candidates.generated.ts index 3763b042c3..bf0de32a66 100644 --- a/src/server/handlers/dev/framework-candidates.generated.ts +++ b/src/server/handlers/dev/framework-candidates.generated.ts @@ -7222,7 +7222,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "chat/components/reasoning.tsx", "chat/components/tool-ui.tsx", "chat/index.tsx", - "chat/json-value.ts", "chat/ui", "chat:", "chat;", @@ -17489,6 +17488,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "veryfront/agent/react/use-agent-metadata.ts", "veryfront/agent/react/use-chat/use-chat.ts", "veryfront/chat", + "veryfront/chat/json-value.ts", "veryfront/chat/uploads", "veryfront/platform/compat/runtime.ts", "veryfront/react/compat/scope-commit-effect.ts",