diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c1c9bb8821..fcde995f387 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -112,7 +112,7 @@ jobs: run: npm install - name: Install Playwright - run: npx playwright install chromium + run: npx playwright install chromium --no-shell --with-deps - name: Run Playwright tests run: npm run test -- --filter @liveblocks/next-sandbox @@ -153,7 +153,7 @@ jobs: run: npm install - name: Install Playwright - run: npx playwright install chromium + run: npx playwright install chromium --no-shell --with-deps - name: Run AI Playwright tests run: npm run test -- --filter @liveblocks/next-ai-kitchen-sink diff --git a/CHANGELOG.md b/CHANGELOG.md index 469b3dc2929..aca3b6f59f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ ## vNEXT (not yet published) +## v3.4.0 + +### `@liveblocks/react` + +Tool calls will now stream in while under construction. This means that tools +will render sooner and more often re-render, while `partialArgs` are streaming +in. + +> New behavior (>=3.4): +> +> - 1st render: `{ stage: "receiving", partialArgs: {} }` +> - 2nd render: `{ stage: "receiving", partialArgs: { cities: [] } }` +> - 3rd render: `{ stage: "receiving", partialArgs: { cities: [""] } }` +> - 4th render: `{ stage: "receiving", partialArgs: { cities: ["Pa"] } }` +> - 5th render: `{ stage: "receiving", partialArgs: { cities: ["Paris"] } }` +> - etc. +> - Then `{ stage: "executing", args: { cities: "Paris" } }` (same as before) +> - And `{ stage: "executed", args, result }` (same as before) +> +> Before (<3.4): +> +> - Stage "receiving" would never happen +> - 1st render would be with +> `{ stage: "executing", args: { cities: ["Paris"] } }` +> - 2nd render would be with `{ stage: "executed", args, result }` + +#### Other changes + +- In `RoomProvider`, `initialPresence` and `initialStorage` now get re-evaluated + whenever the room ID (the `id` prop) changes. + +### `@liveblocks/react-ui` + +- Add a minimal appearance to `AiTool` via a new `variant` prop. +- Improve Markdown rendering during streaming in `AiChat`: incomplete content is + now handled gracefully so things like bold, links, or tables all render + instantly without seeing partial Markdown syntax first. +- Render all messages in `AiChat` as Markdown, including ones from the user. +- Fix Markdown rendering of HTML tags in `AiChat`. (e.g. "Use the `` + component" would render as "Use the `` component") +- Improve shimmer animation visible on elements like the + "Thinking…"/"Reasoning…" placeholders in `AiChat`. + ## v3.3.4 ### `@liveblocks/client` diff --git a/CHANGELOG_PUBLIC.md b/CHANGELOG_PUBLIC.md index bafbc7cc7ed..1b65b65423c 100644 --- a/CHANGELOG_PUBLIC.md +++ b/CHANGELOG_PUBLIC.md @@ -18,6 +18,59 @@ list and feel free to give them credit at the end of a line, e.g.: --> +# Week 34 (2025-08-22) + +## v3.4.0 + +### `@liveblocks/react` + +Tool calls will now stream in while under construction. This means that tools +will render sooner and more often re-render, while `partialArgs` are streaming +in. + +> New behavior (>=3.4): +> +> - 1st render: `{ stage: "receiving", partialArgs: {} }` +> - 2nd render: `{ stage: "receiving", partialArgs: { cities: [] } }` +> - 3rd render: `{ stage: "receiving", partialArgs: { cities: [""] } }` +> - 4th render: `{ stage: "receiving", partialArgs: { cities: ["Pa"] } }` +> - 5th render: `{ stage: "receiving", partialArgs: { cities: ["Paris"] } }` +> - etc. +> - Then `{ stage: "executing", args: { cities: "Paris" } }` (same as before) +> - And `{ stage: "executed", args, result }` (same as before) +> +> Before (<3.4): +> +> - Stage "receiving" would never happen +> - 1st render would be with +> `{ stage: "executing", args: { cities: ["Paris"] } }` +> - 2nd render would be with `{ stage: "executed", args, result }` + +#### Other changes + +- In `RoomProvider`, `initialPresence` and `initialStorage` now get re-evaluated + whenever the room ID (the `id` prop) changes. + +### `@liveblocks/react-ui` + +- Add a minimal appearance to `AiTool` via a new `variant` prop. +- Improve Markdown rendering during streaming in `AiChat`: incomplete content is + now handled gracefully so things like bold, links, or tables all render + instantly without seeing partial Markdown syntax first. +- Render all messages in `AiChat` as Markdown, including ones from the user. +- Fix Markdown rendering of HTML tags in `AiChat`. (e.g. "Use the `` + component" would render as "Use the `` component") +- Improve shimmer animation visible on elements like the + "Thinking…"/"Reasoning…" placeholders in `AiChat`. + +## Infrastructure + +- Improved LiveList conflict resolution that will keep the conflicting element closer to its intended destination. + +## Contributors + +nvie, marcbouchenoire + # Week 33 (2025-08-15) ## v3.3.4 diff --git a/docs/pages/api-reference/liveblocks-client.mdx b/docs/pages/api-reference/liveblocks-client.mdx index abdc31d31fe..3c38dd3cb73 100644 --- a/docs/pages/api-reference/liveblocks-client.mdx +++ b/docs/pages/api-reference/liveblocks-client.mdx @@ -923,9 +923,10 @@ passed directly to [`AiChat`](/docs/api-reference/liveblocks-react-ui#AiChat). Rendering a tool call can be done before the tool call is executed, which allows you to display a UI during its entire lifecycle. The tool call stages are: -- `receiving` The tool call is being received. This stage is reserved for future - use, to enable showing a UI while the tool call `args` object is being - streamed in, but before the tool call can actually be made. +- `receiving` (since [3.4]()) The tool call is being received and its args are + being streamed in. During this stage, you can access `partialArgs` to display + a UI while the tool call arguments are still being constructed, but before the + tool call is executed. - `executing` The tool call is currently executing, or is ready to be. In this stage, the `args` are fully known, but the result of the tool call is not known yet. @@ -1040,6 +1041,125 @@ value of this shape: - `{ cancel: true | string }` If the tool call should be cancelled. You can optionally provide a cancel reason as an instruction to the AI copilot. +#### Handling different tool call stages [#handling-stages] + +You can handle all three stages of a tool call in your render function to +provide a smooth user experience during tool call streaming and execution: + +```tsx +const bookFlightTool = defineAiTool()({ + description: "Book a flight for a user", + parameters: { + type: "object", + properties: { + origin: { type: "string", description: "Departure city" }, + destination: { type: "string", description: "Arrival city" }, + departureDate: { + type: "string", + description: "Departure date (YYYY-MM-DD)", + }, + passengers: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name", "age"], + additionalProperties: false, + }, + description: "List of passengers", + }, + }, + required: ["origin", "destination", "departureDate", "passengers"], + additionalProperties: false, + }, + execute: async ({ origin, destination, departureDate, passengers }) => { + const booking = await bookFlight({ + origin, + destination, + departureDate, + passengers, + }); + return { data: { bookingId: booking.id } }; + }, + render: ({ stage, partialArgs, args, result }) => { + return ( + + {stage === "receiving" && ( +
+

Preparing flight booking...

+ {partialArgs.origin &&

From: {partialArgs.origin}

} + {partialArgs.destination &&

To: {partialArgs.destination}

} + {partialArgs.departureDate && ( +

Date: {partialArgs.departureDate}

+ )} + {partialArgs.passengers && ( +
+

Passengers ({partialArgs.passengers.length}):

+
    + {partialArgs.passengers.map((passenger, index) => ( +
  • + {passenger?.name || "Loading..."} + {passenger?.age && ` (${passenger.age})`} +
  • + ))} +
+
+ )} +
+ )} + {stage === "executing" && ( +
+

Booking flight...

+

+ {args.origin} → {args.destination} on {args.departureDate} +

+

{args.passengers.length} passenger(s)

+
+ )} + {stage === "executed" && result.data && ( +
+

Flight booked successfully!

+

Booking ID: {result.data.bookingId}

+
+ )} +
+ ); + }, +}); +``` + +In this example, the tool arguments stream in progressively during the +`receiving` stage, causing multiple re-renders as each field appears: + +- **1st render**: `{ stage: "receiving", partialArgs: {} }` +- **2nd render**: `{ stage: "receiving", partialArgs: { origin: "New York" } }` +- **3rd render**: + `{ stage: "receiving", partialArgs: { origin: "New York", destination: "London" } }` +- **4th render**: + `{ stage: "receiving", partialArgs: { origin: "New York", destination: "London", departureDate: "2024-12-15" } }` +- **5th render**: `{ stage: "receiving", partialArgs: { ..., passengers: [] } }` +- **6th render**: + `{ stage: "receiving", partialArgs: { ..., passengers: [{ name: "John" }] } }` +- **7th render**: + `{ stage: "receiving", partialArgs: { ..., passengers: [{ name: "John", age: 3 }] } }` +- **8th render**: + `{ stage: "receiving", partialArgs: { ..., passengers: [{ name: "John", age: 30 }] } }` +- **Final render**: `{ stage: "executing", args: { /* complete object */ } }` + +This demonstrates how each field and nested property appears incrementally, +providing real-time feedback to users as the AI constructs the tool call +arguments. + +Arguments are streamed in forward-only order. Once a field begins appearing, all +previous fields are complete and won’t be modified. You’ll never see +`{ origin: "New York", destination: "London" }` followed by +`{ origin: "San Francisco", destination: "London" }`, but you might see +`{ origin: "New" }` then `{ origin: "New York" }` then +`{ origin: "New York", destination: "London" }`. + ## Room Room returned by [`client.enterRoom`][] (or [`client.getRoom`][]). @@ -5653,3 +5773,4 @@ the current user receives in the current room. [`ws`]: https://www.npmjs.com/package/ws [connection status example]: https://liveblocks.io/examples/connection-status/nextjs +[3.4]: https://github.com/liveblocks/liveblocks/releases/tag/v3.4.0 diff --git a/docs/pages/api-reference/liveblocks-react-ui.mdx b/docs/pages/api-reference/liveblocks-react-ui.mdx index 774a3970062..a0e526bb44b 100644 --- a/docs/pages/api-reference/liveblocks-react-ui.mdx +++ b/docs/pages/api-reference/liveblocks-react-ui.mdx @@ -668,6 +668,10 @@ render: () => ( Content to display inside the tool container. Typically used for tool-specific UI or descriptions. + + The visual appearance of the tool. The `"block"` variant (default) displays + the tool as a block with a border. + Whether the tool content should be collapsed. When collapsed, only the title and icon are visible. diff --git a/e2e/next-ai-kitchen-sink/app/cleanup/[chatId]/page.tsx b/e2e/next-ai-kitchen-sink/app/cleanup/[chatId]/page.tsx new file mode 100644 index 00000000000..010514ac8d2 --- /dev/null +++ b/e2e/next-ai-kitchen-sink/app/cleanup/[chatId]/page.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { use, useEffect, useState } from "react"; +import { LiveblocksProvider, useDeleteAiChat } from "@liveblocks/react"; + +function ChatDeleter({ chatId }: { chatId: string }) { + const deleteChat = useDeleteAiChat(); + const [deleted, setDeleted] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + try { + deleteChat(chatId); + setDeleted(true); + } catch (err) { + console.error("Failed to delete chat:", err); + setError(err instanceof Error ? err.message : String(err)); + } + }, [deleteChat, chatId]); + + if (error) { + return
Error deleting chat: {error}
; + } + + if (deleted) { + return
Chat deleted
; + } + + return
Deleting chat...
; +} + +export default function CleanupPage({ + params, +}: { + params: Promise<{ chatId: string }>; +}) { + const { chatId } = use(params); + + return ( + + + + ); +} diff --git a/e2e/next-ai-kitchen-sink/app/dual-chat/[chatId1]/[chatId2]/page.tsx b/e2e/next-ai-kitchen-sink/app/dual-chat/[chatId1]/[chatId2]/page.tsx new file mode 100644 index 00000000000..16b73492b96 --- /dev/null +++ b/e2e/next-ai-kitchen-sink/app/dual-chat/[chatId1]/[chatId2]/page.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { use } from "react"; +import { LiveblocksProvider, RegisterAiKnowledge } from "@liveblocks/react"; +import { AiChat } from "@liveblocks/react-ui"; +import { useState } from "react"; + +function ChatWithLocalKnowledge({ chatId }: { chatId: string }) { + const [localKnowledge, setLocalKnowledge] = useState("Spaghetti Carbonara"); + + return ( +
+
+ + setLocalKnowledge(e.target.value)} + className="w-full p-2 border border-blue-300 rounded bg-blue-50" + data-testid="chat-a-knowledge-input" + /> +

+ This knowledge is passed via the knowledge prop to chat A + only. +

+
+ +

Chat A

+
+ +
+
+ ); +} + +function ChatWithoutLocalKnowledge({ chatId }: { chatId: string }) { + return ( +
+
+

+ No local knowledge +

+

+ This chat has no additional local knowledge passed via props. It can + only access the global knowledge. +

+
+ +

Chat B

+
+ +
+
+ ); +} + +export default function DualChatPage({ params }: { params: Promise<{ chatId1: string; chatId2: string }> }) { + const { chatId1, chatId2 } = use(params); + const [globalKnowledge, setGlobalKnowledge] = useState("Tiramisu"); + + return ( + + {/* Global knowledge that should be accessible to ALL chat instances */} + + +
+
+

Knowledge Isolation Test

+
+ + setGlobalKnowledge(e.target.value)} + className="w-full p-2 border border-yellow-300 rounded bg-yellow-50" + data-testid="global-knowledge-input" + /> +

+ This knowledge is registered via{" "} + <RegisterAiKnowledge /> and should be + accessible to both chat A and B. +

+
+
+ +
+ + +
+
+
+ ); +} diff --git a/e2e/next-ai-kitchen-sink/app/dual-chat/page.tsx b/e2e/next-ai-kitchen-sink/app/dual-chat/page.tsx index 23ceedbb18d..d86034b9080 100644 --- a/e2e/next-ai-kitchen-sink/app/dual-chat/page.tsx +++ b/e2e/next-ai-kitchen-sink/app/dual-chat/page.tsx @@ -1,111 +1,21 @@ "use client"; -import { LiveblocksProvider, RegisterAiKnowledge } from "@liveblocks/react"; -import { AiChat } from "@liveblocks/react-ui"; -import { useState } from "react"; +import { nanoid } from "@liveblocks/core"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; -function ChatWithLocalKnowledge() { - const [localKnowledge, setLocalKnowledge] = useState("Spaghetti Carbonara"); +export default function Page() { + const router = useRouter(); - return ( -
-
- - setLocalKnowledge(e.target.value)} - className="w-full p-2 border border-blue-300 rounded bg-blue-50" - data-testid="chat-a-knowledge-input" - /> -

- This knowledge is passed via the knowledge prop to chat A - only. -

-
- -

Chat A

-
- -
-
- ); -} + useEffect(() => { + const chatId1 = nanoid(); + const chatId2 = nanoid(); + router.replace(`/dual-chat/${chatId1}/${chatId2}`); + }, [router]); -function ChatWithoutLocalKnowledge() { return ( -
-
-

- No local knowledge -

-

- This chat has no additional local knowledge passed via props. It can - only access the global knowledge. -

-
- -

Chat B

-
- -
+
+
Loading...
); -} - -export default function DualChatPage() { - const [globalKnowledge, setGlobalKnowledge] = useState("Tiramisu"); - - return ( - - {/* Global knowledge that should be accessible to ALL chat instances */} - - -
-
-

Knowledge Isolation Test

-
- - setGlobalKnowledge(e.target.value)} - className="w-full p-2 border border-yellow-300 rounded bg-yellow-50" - data-testid="global-knowledge-input" - /> -

- This knowledge is registered via{" "} - <RegisterAiKnowledge /> and should be - accessible to both chat A and B. -

-
-
- -
- - -
-
-
- ); -} +} \ No newline at end of file diff --git a/e2e/next-ai-kitchen-sink/app/globals.css b/e2e/next-ai-kitchen-sink/app/globals.css index 8187d73fd6f..af28c899b00 100644 --- a/e2e/next-ai-kitchen-sink/app/globals.css +++ b/e2e/next-ai-kitchen-sink/app/globals.css @@ -1,4 +1,25 @@ @import "tailwindcss"; + +@theme { + --color-neutral-950: #0a0a0a; + --color-neutral-900: #171717; + --color-neutral-800: #262626; + --color-neutral-700: #404040; + --color-neutral-100: #f5f5f5; + --color-neutral-50: #fafafa; +} + +@variant dark (.dark &); + +@layer base { + html { + color-scheme: light; + } + + .dark html { + color-scheme: dark; + } +} @import "./typography.css"; @import "@liveblocks/react-ui/styles.css"; @import "@liveblocks/react-ui/styles/dark/media-query.css"; diff --git a/e2e/next-ai-kitchen-sink/app/knowledge/[chatId]/page.tsx b/e2e/next-ai-kitchen-sink/app/knowledge/[chatId]/page.tsx new file mode 100644 index 00000000000..7a7fcc93515 --- /dev/null +++ b/e2e/next-ai-kitchen-sink/app/knowledge/[chatId]/page.tsx @@ -0,0 +1,330 @@ +"use client"; + +import { use } from "react"; +import { defineAiTool } from "@liveblocks/client"; +import { + ClientSideSuspense, + LiveblocksProvider, + RegisterAiKnowledge, + RegisterAiTool, +} from "@liveblocks/react/suspense"; +import { AiChat, AiTool } from "@liveblocks/react-ui"; + +import { useCallback, useEffect, useState } from "react"; +import { Popover } from "radix-ui"; + +function DarkModeToggle() { + const [exposed, setExposed] = useState(true); + const [mode, setMode] = useState<"dark" | "light">("light"); + + const toggleExposeTool = useCallback(() => { + setExposed((exposeTool) => !exposeTool); + }, []); + + const toggleDarkMode = useCallback(() => { + setMode(mode === "dark" ? "light" : "dark"); + }, [mode]); + + useEffect(() => { + if (mode === "dark") { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } + }, [mode]); + + return ( +
+ + + {exposed ? ( + + ) : null} + + {exposed ? ( + { + setMode(mode); + return { + data: { ok: true, message: `Dark mode changed to ${mode}` }, + }; + }, + render: () => , + })} + /> + ) : null} + + +
+ ); +} + +function MyNickName() { + const [enabled, setEnabled] = useState(false); + const toggle = useCallback(() => { + setEnabled((enabled) => !enabled); + }, []); + + return ( + <> + {enabled ? ( + + ) : null} +
+ +
+ + ); +} + +function TodoApp() { + const [todos, setTodos] = useState< + { id: number; title: string; isCompleted: boolean }[] + >([ + { + id: 1, + title: "Get groceries", + isCompleted: true, + }, + { + id: 2, + title: "Go to the gym", + isCompleted: false, + }, + { + id: 3, + title: "Cook dinner", + isCompleted: false, + }, + ]); + const [value, setValue] = useState(""); + + return ( +
+ + + + + { + setValue(e.currentTarget.value); + }} + onKeyDown={(e) => { + if (value.length > 0 && e.key === "Enter") { + setTodos((todos) => [ + ...todos, + { + id: todos.length + 1, + title: value, + isCompleted: false, + }, + ]); + setValue(""); + } + }} + className="shadow-[0_0_0_1px_rgb(0_0_0_/_4%),_0_2px_6px_rgb(0_0_0_/_4%),_0_8px_26px_rgb(0_0_0_/_6%)] dark:shadow-[0_0_0_1px_rgb(255_255_255_/_10%),_0_2px_6px_rgb(0_0_0_/_20%),_0_8px_26px_rgb(0_0_0_/_30%)] rounded-lg p-3 w-full border-0 mb-4 dark:bg-neutral-800 dark:text-neutral-100" + /> + + {todos.length > 0 && ( +
    + {todos.map((todo, index) => { + return ( +
  • { + setTodos((todos) => + todos.map((t) => { + if (t.id === todo.id) { + return { ...todo, isCompleted: !todo.isCompleted }; + } + return t; + }) + ); + }} + > + {todo.title} +
  • + ); + })} +
+ )} +
+ ); +} + +function AnotherApp() { + return ( +
+ + Another part of the app +
+ ); +} + +function BothApps() { + return ( + <> + + + + + ); +} + +export default function Page({ params }: { params: Promise<{ chatId: string }> }) { + const { chatId } = use(params); + const [selectedTab, setSelectedTab] = useState(1); + return ( + +
+ + + +
+
+ + + +
+ + {selectedTab === 1 ? ( + + ) : selectedTab === 2 ? ( + + ) : ( + + )} +
+ +
+ + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/e2e/next-ai-kitchen-sink/app/knowledge/page.tsx b/e2e/next-ai-kitchen-sink/app/knowledge/page.tsx index a22b80c7d78..5fc4caa219f 100644 --- a/e2e/next-ai-kitchen-sink/app/knowledge/page.tsx +++ b/e2e/next-ai-kitchen-sink/app/knowledge/page.tsx @@ -1,320 +1,20 @@ "use client"; -import { defineAiTool } from "@liveblocks/client"; -import { - ClientSideSuspense, - LiveblocksProvider, - RegisterAiKnowledge, - RegisterAiTool, -} from "@liveblocks/react/suspense"; -import { AiChat, AiTool } from "@liveblocks/react-ui"; +import { nanoid } from "@liveblocks/core"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; -import { useCallback, useState } from "react"; -import { Popover } from "radix-ui"; - -function DarkModeToggle() { - const [exposed, setExposed] = useState(true); - const [mode, setMode] = useState<"dark" | "light">("light"); - - const toggleExposeTool = useCallback(() => { - setExposed((exposeTool) => !exposeTool); - }, []); - - const toggleDarkMode = useCallback(() => { - setMode(mode === "dark" ? "light" : "dark"); - }, [mode]); - - return ( -
- - - {exposed ? ( - - ) : null} - - {exposed ? ( - { - setMode(mode); - return { - data: { ok: true, message: `Dark mode changed to ${mode}` }, - }; - }, - render: () => , - })} - /> - ) : null} - - -
- ); -} - -function MyNickName() { - const [enabled, setEnabled] = useState(false); - const toggle = useCallback(() => { - setEnabled((enabled) => !enabled); - }, []); - - return ( - <> - {enabled ? ( - - ) : null} -
- -
- - ); -} - -function TodoApp() { - const [todos, setTodos] = useState< - { id: number; title: string; isCompleted: boolean }[] - >([ - { - id: 1, - title: "Get groceries", - isCompleted: true, - }, - { - id: 2, - title: "Go to the gym", - isCompleted: false, - }, - { - id: 3, - title: "Cook dinner", - isCompleted: false, - }, - ]); - const [value, setValue] = useState(""); - - return ( -
- - - - - { - setValue(e.currentTarget.value); - }} - onKeyDown={(e) => { - if (value.length > 0 && e.key === "Enter") { - setTodos((todos) => [ - ...todos, - { - id: todos.length + 1, - title: value, - isCompleted: false, - }, - ]); - setValue(""); - } - }} - className="shadow-[0_0_0_1px_rgb(0_0_0_/_4%),_0_2px_6px_rgb(0_0_0_/_4%),_0_8px_26px_rgb(0_0_0_/_6%)] rounded-lg p-3 w-full border-0 mb-4" - /> +export default function Page() { + const router = useRouter(); - {todos.length > 0 && ( -
    - {todos.map((todo, index) => { - return ( -
  • { - setTodos((todos) => - todos.map((t) => { - if (t.id === todo.id) { - return { ...todo, isCompleted: !todo.isCompleted }; - } - return t; - }) - ); - }} - > - {todo.title} -
  • - ); - })} -
- )} -
- ); -} + useEffect(() => { + router.replace(`/knowledge/${nanoid()}`); + }, [router]); -function AnotherApp() { + // Show loading while redirecting return ( -
- - Another part of the app +
+
Loading...
); -} - -function BothApps() { - return ( - <> - - - - - ); -} - -export default function Page() { - const [selectedTab, setSelectedTab] = useState(1); - return ( - -
- - - -
-
- - - -
- - {selectedTab === 1 ? ( - - ) : selectedTab === 2 ? ( - - ) : ( - - )} -
- -
- - - - - - - - - - - - - - -
-
-
- ); -} +} \ No newline at end of file diff --git a/e2e/next-ai-kitchen-sink/app/page.tsx b/e2e/next-ai-kitchen-sink/app/page.tsx index 07afdac83e3..42acc730e5c 100644 --- a/e2e/next-ai-kitchen-sink/app/page.tsx +++ b/e2e/next-ai-kitchen-sink/app/page.tsx @@ -26,6 +26,12 @@ export default function Home() { Default styles playground + + Tool call streaming + + + Partial markdown streaming +
); diff --git a/e2e/next-ai-kitchen-sink/app/partial-markdown/page.tsx b/e2e/next-ai-kitchen-sink/app/partial-markdown/page.tsx new file mode 100644 index 00000000000..f501bfc019e --- /dev/null +++ b/e2e/next-ai-kitchen-sink/app/partial-markdown/page.tsx @@ -0,0 +1,499 @@ +"use client"; + +import { + HTMLAttributes, + useState, + useRef, + useCallback, + useEffect, +} from "react"; +import { Prose } from "@liveblocks/react-ui/_private"; + +function TextPart({ + text, + partial, + ...props +}: HTMLAttributes & { text: string; partial?: boolean }) { + return ( + + ); +} + +const markdownMessage = ` +# The long and winding road that leads to your door will never disappear, I've seen that road before it always leads me here, lead me to your door + +## Here we are, stuck by this river, you and I, underneath the sky that's ever falling down, down, down, ever falling down + +### Like a heartbeat drives you mad, in the stillness of remembering, what you had and what you lost, and what you had and what you lost + +#### Kim and Jessie, they have a secret world in the twilight, kids of the woods, they're crazy about romance and illusions + +##### You spent the first five years trying to get with the plan, and the next five years trying to be with your friends again + +###### Because in this city's barren cold, I still remember the first fall of snow, and how it glistened as it fell, I remember it all too well + +\\*Not italic\\* and \\[not a link\\]\\(https://liveblocks.io\). + +--- + +This is a regular paragraph of text. It includes **bold text**, _italic text_, **_bold and italic_**, ~~strikethrough~~, \`inline code\`, **\`bold inline code\`**, and [links](https://liveblocks.io/ "With a title"). +Hello world the rest of the first paragraph is here. + +![An image](https://liveblocks.io/favicon.svg) + +Here’s a second paragraph to test spacing between multiple paragraphs. + +> This is a blockquote. +> It can span multiple **lines. +> It** also includes \`code\`, **bold**, and links inside the blockquote. + +> This is a blockquote. +> +> It also includes \`code\`, **bold**, and links inside the blockquote. + +- A list item + +- Another list item with + + multiple paragraphs. + + | Syntax | Description | + | --------- | ----------- | + | Header | Title | + | Paragraph | Text | + + - A nested list item + +- [x] A task list item with + + > a quote and a code block + + ### test + + \`\`\` + const a = 2; + \`\`\` + +--- + +### Unordered List + +- A root item + - A nested item + - A long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ + - Another nested item +- Another long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ + +### Ordered List + +1. A root item +2. An item with children + 1. A long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ + 2. A nested item +3. Another long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ + +### Mixed Lists + +- A unordered list item + 1. A nested ordered list item with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~s + 2. Another nested ordered list item +- A unordered list item with **bold text** + +### Task List + +- [ ] A task list item +- [x] A completed task list item +- [ ] A task list item with a [link](https://liveblocks.io/) +- [x] A completed task list item with **bold text** +- [ ] A task list item with ~~strikethrough~~ + +--- + +# H1 Heading with \`code\` + +Some text directly under a heading. + +## H2 Heading with \`code\` + +Some text directly under a heading. + +### H3 Heading with \`code\` + +Some text directly under a heading. + +#### H4 Heading with \`code\` + +Some text directly under a heading. + +##### H5 Heading with \`code\` + +Some text directly under a heading. + +###### H6 Heading with \`code\` + +Some text directly under a heading. + +--- + +# H1 Heading + +\`\`\`tsx +import { useEffect, useState } from "react"; + +type User = { + id: string; + name: string; +}; + +export default function UsersList() { + const [users, setUsers] = useState([]); + + useEffect(() => { + fetch("/api/users") + .then((res) => res.json()) + .then(setUsers); + }, []); + + return ( +
    + {users.map((user) => ( +
  • {user.name}
  • + ))} +
+ ); +} +\`\`\` + +- A list item +- A list item +- A list item + +\`\`\`bash +# A multi-line shell script example +set -e + +echo "Starting server..." +bun run dev + +echo "Done!" +\`\`\` + +## H2 Heading + +\`\`\`json +{ + "name": "my-app", + "version": "1.0.0", + "scripts": { + "dev": "bun run dev", + "build": "bun run build" + }, + "dependencies": { + "react": "^18.0.0", + "next": "^14.0.0" + } +} +\`\`\` + +> A blockquote + +\`\`\`css +.prose { + max-width: 65ch; + line-height: 1.75; + font-feature-settings: "liga" 1; +} +\`\`\` + +### H3 Heading + +\`\`\`html + + + + + Example + + +

Hello, world!

+ + +\`\`\` + +Some text between two code blocks. + +\`\`\`css +.prose { + max-width: 65ch; + line-height: 1.75; + font-feature-settings: "liga" 1; +} +\`\`\` + +#### H4 Heading + +\`\`\`json +{ + "name": "my-app", + "version": "1.0.0", + "scripts": { + "dev": "bun run dev", + "build": "bun run build" + }, + "dependencies": { + "react": "^18.0.0", + "next": "^14.0.0" + } +} +\`\`\` + +##### H5 Heading + +\`\`\`tsx +
+

Hello, world!

+
+\`\`\` + +###### H6 Heading + +\`\`\` +A code block without any language +Just some plain text +\`\`\` + +--- + +| Syntax *italic* and \`code\` | Description | +| --------- | ----------- | +| Header | Title | +| Paragraph | Text | + +| Feature | Example | Notes | +| ------------- | ------------------------------------ | ------------------------- | +| Link | [Liveblocks](https://liveblocks.io/) | External link | +| Inline code | \`const x = 42;\` | Code inside table | +| Bold text | **Important** | Styling test | +| Italic text | _Emphasis_ | Test italic inside tables | +| Strikethrough | ~~Deprecated~~ | Show removal | + +--- + +> ### Quoted Heading +> +> - A quoted unordered list item +> - Another quoted unordered list item +> +> 1. A quoted ordered list item +> 2. Another quoted ordered list item +> +> \`\`\`ts +> const x = 1; +> \`\`\` + +> Outer quote +> +> > Inner quote +> > +> > - With list +> > - And \`code\` + +--- + +1. A numbered list item +- A "nested" list item +- Another "nested" list item + +2. Another numbered list item +- A "nested" list item +- Another "nested" list item + +3. Yet another numbered list item +- A "nested" list item +- Another "nested" list item + +--- + +1. A numbered list item + +\`\`\` +const a = 2; +\`\`\` + +2. Another numbered list item + +> A quote. + +3. Yet another numbered list item + +A paragraph. + +--- + +1. A numbered list item +1. Another numbered list item +1. Yet another numbered list item + +--- + +The abbreviation for HyperText Markup Language is HTML. + +Press Ctrl + C to copy. + +This is highlighted text. + +E = mc2 +`; + +export default function Home() { + const min = 0; + const max = markdownMessage.length; + const [value, setValue] = useState(min); + const timeoutRef = useRef | null>(null); + const intervalRef = useRef | null>(null); + + const clearTimeoutAndInterval = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + const handlePointerDown = useCallback( + (direction: "previous" | "next") => { + const change = direction === "next" ? 1 : -1; + + clearTimeoutAndInterval(); + + setValue((last) => Math.max(min, Math.min(max, last + change))); + + timeoutRef.current = setTimeout(() => { + intervalRef.current = setInterval(() => { + setValue((last) => Math.max(min, Math.min(max, last + change))); + }, 75); + }, 500); + }, + [clearTimeoutAndInterval, min, max] + ); + + const handlePointerUp = useCallback(() => { + clearTimeoutAndInterval(); + }, [clearTimeoutAndInterval]); + + useEffect(() => { + return () => { + clearTimeoutAndInterval(); + }; + }, [clearTimeoutAndInterval]); + + return ( +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ {/* Debug panel */} +
+ setValue(Number(event.target.value))} + style={{ + flex: 1, + }} + /> + + +
+
+
+
+
+ ); +} diff --git a/e2e/next-ai-kitchen-sink/app/simple/[chatId]/page.tsx b/e2e/next-ai-kitchen-sink/app/simple/[chatId]/page.tsx new file mode 100644 index 00000000000..55a76fc8702 --- /dev/null +++ b/e2e/next-ai-kitchen-sink/app/simple/[chatId]/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { use } from "react"; +import { AiChat } from "@liveblocks/react-ui"; +import { LiveblocksProvider } from "@liveblocks/react"; + +export default function Home({ params }: { params: Promise<{ chatId: string }> }) { + const { chatId } = use(params); + return ( +
+ +
+ +
+
+
+ ); +} diff --git a/e2e/next-ai-kitchen-sink/app/simple/page.tsx b/e2e/next-ai-kitchen-sink/app/simple/page.tsx index 771a11fa14d..9a82115204b 100644 --- a/e2e/next-ai-kitchen-sink/app/simple/page.tsx +++ b/e2e/next-ai-kitchen-sink/app/simple/page.tsx @@ -1,20 +1,19 @@ "use client"; -import { AiChat } from "@liveblocks/react-ui"; -import { LiveblocksProvider } from "@liveblocks/react"; +import { nanoid } from "@liveblocks/core"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function Page() { + const router = useRouter(); + + useEffect(() => { + router.replace(`/simple/${nanoid()}`); + }, [router]); -export default function Home() { return ( -
- -
- -
-
-
+
+
Loading...
+
); -} +} \ No newline at end of file diff --git a/e2e/next-ai-kitchen-sink/app/styles/[chatId]/page.tsx b/e2e/next-ai-kitchen-sink/app/styles/[chatId]/page.tsx new file mode 100644 index 00000000000..365a3d17a56 --- /dev/null +++ b/e2e/next-ai-kitchen-sink/app/styles/[chatId]/page.tsx @@ -0,0 +1,630 @@ +"use client"; + +import { + HTMLAttributes, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + ChevronRightIcon, + WarningIcon, + Collapsible, + Prose, +} from "@liveblocks/react-ui/_private"; + +function TextPart({ + text, + ...props +}: HTMLAttributes & { text: string }) { + return ( + + ); +} + +function ReasoningPart({ + text, + isPending, +}: { + text: string; + isPending: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + return ( + + + Reasoning + + + + + + + + + + ); +} + +const reasoningMessage = ` +This is a reasoning message, it can also include **bold text**, _italic text_, **_bold and italic_**, ~~strikethrough~~, \`inline code\`, **\`bold inline code\`**, and [links](https://liveblocks.io/). + +It can also include multiple paragraphs, headings, blockquotes, lists, code blocks, and more. + +> This is a blockquote. +> It can span multiple lines. +> It also includes \`code\`, **bold**, and links inside the blockquote. + +### Unordered List + +- A root item + - A nested item + +### Ordered List + +1. A root item +2. An item with children + 1. A long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ + 2. A nested item + +\`\`\`tsx +const x = 42; +\`\`\` + +\`\`\`json +{ "name": "my-app", "version": "1.0.0" } +\`\`\` +`; + +const simpleMarkdownMessage = ` +This is a regular paragraph of text. It includes **bold text**, _italic text_, **_bold and italic_**, ~~strikethrough~~, \`inline code\`, **\`bold inline code\`**, and [links](https://liveblocks.io/). + +Here’s a second paragraph to test spacing between multiple paragraphs. + +> This is a blockquote. +> It can span multiple lines. +> It also includes \`code\`, **bold**, and links inside the blockquote. +`; + +const markdownMessage = ` +# The long and winding road that leads to your door will never disappear, I've seen that road before it always leads me here, lead me to your door + +## Here we are, stuck by this river, you and I, underneath the sky that's ever falling down, down, down, ever falling down + +### Like a heartbeat drives you mad, in the stillness of remembering, what you had and what you lost, and what you had and what you lost + +#### Kim and Jessie, they have a secret world in the twilight, kids of the woods, they're crazy about romance and illusions + +##### You spent the first five years trying to get with the plan, and the next five years trying to be with your friends again + +###### Because in this city's barren cold, I still remember the first fall of snow, and how it glistened as it fell, I remember it all too well + +--- + +This is a regular paragraph of text. It includes **bold text**, _italic text_, **_bold and italic_**, ~~strikethrough~~, \`inline code\`, **\`bold inline code\`**, and [links](https://liveblocks.io/). + +Here’s a second paragraph to test spacing between multiple paragraphs. + +> This is a blockquote. +> It can span multiple lines. +> It also includes \`code\`, **bold**, and links inside the blockquote. + +--- + +### Unordered List + +- A root item + - A nested item + - A long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ + - Another nested item +- Another long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ + +### Ordered List + +1. A root item +2. An item with children + 1. A long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ + 2. A nested item +3. Another long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ + +### Mixed Lists + +- A unordered list item + 1. A nested ordered list item with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~s + 2. Another nested ordered list item +- A unordered list item with **bold text** + +### Task List + +- [ ] A task list item +- [x] A completed task list item +- [ ] A task list item with a [link](https://liveblocks.io/) +- [x] A completed task list item with **bold text** +- [ ] A task list item with ~~strikethrough~~ + +--- + +# H1 Heading with \`code\` + +Some text directly under a heading. + +## H2 Heading with \`code\` + +Some text directly under a heading. + +### H3 Heading with \`code\` + +Some text directly under a heading. + +#### H4 Heading with \`code\` + +Some text directly under a heading. + +##### H5 Heading with \`code\` + +Some text directly under a heading. + +###### H6 Heading with \`code\` + +Some text directly under a heading. + +--- + +# H1 Heading + +\`\`\`tsx +import { useEffect, useState } from "react"; + +type User = { + id: string; + name: string; +}; + +export default function UsersList() { + const [users, setUsers] = useState([]); + + useEffect(() => { + fetch("/api/users") + .then((res) => res.json()) + .then(setUsers); + }, []); + + return ( +
    + {users.map((user) => ( +
  • {user.name}
  • + ))} +
+ ); +} +\`\`\` + +- A list item +- A list item +- A list item + +\`\`\`bash +# A multi-line shell script example +set -e + +echo "Starting server..." +bun run dev + +echo "Done!" +\`\`\` + +## H2 Heading + +\`\`\`json +{ + "name": "my-app", + "version": "1.0.0", + "scripts": { + "dev": "bun run dev", + "build": "bun run build" + }, + "dependencies": { + "react": "^18.0.0", + "next": "^14.0.0" + } +} +\`\`\` + +> A blockquote + +\`\`\`css +.prose { + max-width: 65ch; + line-height: 1.75; + font-feature-settings: "liga" 1; +} +\`\`\` + +### H3 Heading + +\`\`\`html + + + + + Example + + +

Hello, world!

+ + +\`\`\` + +Some text between two code blocks. + +\`\`\`css +.prose { + max-width: 65ch; + line-height: 1.75; + font-feature-settings: "liga" 1; +} +\`\`\` + +#### H4 Heading + +\`\`\`json +{ + "name": "my-app", + "version": "1.0.0", + "scripts": { + "dev": "bun run dev", + "build": "bun run build" + }, + "dependencies": { + "react": "^18.0.0", + "next": "^14.0.0" + } +} +\`\`\` + +##### H5 Heading + +\`\`\`tsx +
+

Hello, world!

+
+\`\`\` + +###### H6 Heading + +\`\`\` +A code block without any language +Just some plain text +\`\`\` + +--- + +| Syntax | Description | +| --------- | ----------- | +| Header | Title | +| Paragraph | Text | + +| Feature | Example | Notes | +| ------------- | ------------------------------------ | ------------------------- | +| Link | [Liveblocks](https://liveblocks.io/) | External link | +| Inline code | \`const x = 42;\` | Code inside table | +| Bold text | **Important** | Styling test | +| Italic text | _Emphasis_ | Test italic inside tables | +| Strikethrough | ~~Deprecated~~ | Show removal | + +--- + +> ### Quoted Heading +> +> - A quoted unordered list item +> - Another quoted unordered list item +> +> 1. A quoted ordered list item +> 2. Another quoted ordered list item +> +> \`\`\`ts +> const x = 1; +> \`\`\` + +> Outer quote +> +> > Inner quote +> > +> > - With list +> > - And \`code\` + +--- + +1. A numbered list item +- A "nested" list item +- Another "nested" list item + +2. Another numbered list item +- A "nested" list item +- Another "nested" list item + +3. Yet another numbered list item +- A "nested" list item +- Another "nested" list item + +--- + +1. A numbered list item + +\`\`\` +const a = 2; +\`\`\` + +2. Another numbered list item + +> A quote. + +3. Yet another numbered list item + +A paragraph. + +--- + +1. A numbered list item +1. Another numbered list item +1. Yet another numbered list item + +--- + +The abbreviation for HyperText Markup Language is HTML. + +Press Ctrl + C to copy. + +This is highlighted text. + +E = mc2 +`; + +const lineBreakMessage = ` +A message in a single paragraph with line breaks + +This is a new line + +This is another line +`; + +function useIsMounted() { + const isMounted = useSyncExternalStore( + () => () => {}, + () => true, + () => false + ); + + return isMounted; +} + +function random(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export default function Home() { + const isMounted = useIsMounted(); + + const [isReasoning, setReasoning] = useState(true); + const [isStreaming, setStreaming] = useState(false); + const [streamedMarkdownMessage, setStreamedMarkdownMessage] = useState(""); + const intervalRef = useRef | null>(null); + + useEffect(() => { + // Reset and clear interval when toggling + setStreamedMarkdownMessage(""); + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + if (!isStreaming) { + return; + } + + let index = 0; + + intervalRef.current = setInterval(() => { + index += random(4, 10); + + setStreamedMarkdownMessage(markdownMessage.slice(0, index)); + + if (index >= markdownMessage.length && intervalRef.current) { + clearInterval(intervalRef.current); + } + }, 40); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [isStreaming]); + + if (!isMounted) { + return null; + } + + return ( +
+
+
+
+
+
+ This message has been deleted. +
+
+
+
+ This message has been deleted. +
+
+ +
+
+
+

Some errors

+
+
+
+
+
{/* Empty */}
+
+ + + + An error message with no content +
+
+
+
+ +
+
+ + + + A long error message that wraps across multiple lines and keeps + going forever, with a link to the docs too. + https://liveblocks.io/docs/errors/cross-linked +
+
+ +
+
+
+

A pending message

+
+
+
+
+
+ Thinking… +
+
+
+
+ Thinking but with a longer message to see how flexible the + shimmer is… +
+
+ +
+
+
+

A reasoning message with tools

+
+
+
+
+
+ + +
+
+ A rendered tool +
+
+
+
+ A second rendered tool immediately after the first one +
+
+ +
+
+ A rendered tool as the last part +
+
+
+
+ +
+
+
+

A Markdown message

+
+
+
+
+
+ +
+
+ +
+
+
+

A message with line breaks

+
+
+
+
+
+

{lineBreakMessage}

+
+
+
+
+
+
+ {/* Debug panel */} +
+ + + +
+
+
+
+
+ ); +} diff --git a/e2e/next-ai-kitchen-sink/app/styles/page.tsx b/e2e/next-ai-kitchen-sink/app/styles/page.tsx index 365a3d17a56..c271fd58e9b 100644 --- a/e2e/next-ai-kitchen-sink/app/styles/page.tsx +++ b/e2e/next-ai-kitchen-sink/app/styles/page.tsx @@ -1,630 +1,19 @@ "use client"; -import { - HTMLAttributes, - useEffect, - useRef, - useState, - useSyncExternalStore, -} from "react"; -import { - ChevronRightIcon, - WarningIcon, - Collapsible, - Prose, -} from "@liveblocks/react-ui/_private"; +import { nanoid } from "@liveblocks/core"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; -function TextPart({ - text, - ...props -}: HTMLAttributes & { text: string }) { - return ( - - ); -} - -function ReasoningPart({ - text, - isPending, -}: { - text: string; - isPending: boolean; -}) { - const [isOpen, setIsOpen] = useState(false); - return ( - - - Reasoning - - - - - - - - - - ); -} - -const reasoningMessage = ` -This is a reasoning message, it can also include **bold text**, _italic text_, **_bold and italic_**, ~~strikethrough~~, \`inline code\`, **\`bold inline code\`**, and [links](https://liveblocks.io/). - -It can also include multiple paragraphs, headings, blockquotes, lists, code blocks, and more. - -> This is a blockquote. -> It can span multiple lines. -> It also includes \`code\`, **bold**, and links inside the blockquote. - -### Unordered List - -- A root item - - A nested item - -### Ordered List - -1. A root item -2. An item with children - 1. A long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ - 2. A nested item - -\`\`\`tsx -const x = 42; -\`\`\` - -\`\`\`json -{ "name": "my-app", "version": "1.0.0" } -\`\`\` -`; - -const simpleMarkdownMessage = ` -This is a regular paragraph of text. It includes **bold text**, _italic text_, **_bold and italic_**, ~~strikethrough~~, \`inline code\`, **\`bold inline code\`**, and [links](https://liveblocks.io/). - -Here’s a second paragraph to test spacing between multiple paragraphs. - -> This is a blockquote. -> It can span multiple lines. -> It also includes \`code\`, **bold**, and links inside the blockquote. -`; - -const markdownMessage = ` -# The long and winding road that leads to your door will never disappear, I've seen that road before it always leads me here, lead me to your door - -## Here we are, stuck by this river, you and I, underneath the sky that's ever falling down, down, down, ever falling down - -### Like a heartbeat drives you mad, in the stillness of remembering, what you had and what you lost, and what you had and what you lost - -#### Kim and Jessie, they have a secret world in the twilight, kids of the woods, they're crazy about romance and illusions - -##### You spent the first five years trying to get with the plan, and the next five years trying to be with your friends again - -###### Because in this city's barren cold, I still remember the first fall of snow, and how it glistened as it fell, I remember it all too well - ---- - -This is a regular paragraph of text. It includes **bold text**, _italic text_, **_bold and italic_**, ~~strikethrough~~, \`inline code\`, **\`bold inline code\`**, and [links](https://liveblocks.io/). - -Here’s a second paragraph to test spacing between multiple paragraphs. - -> This is a blockquote. -> It can span multiple lines. -> It also includes \`code\`, **bold**, and links inside the blockquote. - ---- - -### Unordered List - -- A root item - - A nested item - - A long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ - - Another nested item -- Another long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ - -### Ordered List - -1. A root item -2. An item with children - 1. A long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ - 2. A nested item -3. Another long nested item to see how it wraps, with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~ - -### Mixed Lists - -- A unordered list item - 1. A nested ordered list item with a [link](https://liveblocks.io/), **bold text**, and ~~strikethrough~~s - 2. Another nested ordered list item -- A unordered list item with **bold text** - -### Task List - -- [ ] A task list item -- [x] A completed task list item -- [ ] A task list item with a [link](https://liveblocks.io/) -- [x] A completed task list item with **bold text** -- [ ] A task list item with ~~strikethrough~~ - ---- - -# H1 Heading with \`code\` - -Some text directly under a heading. - -## H2 Heading with \`code\` - -Some text directly under a heading. - -### H3 Heading with \`code\` - -Some text directly under a heading. - -#### H4 Heading with \`code\` - -Some text directly under a heading. - -##### H5 Heading with \`code\` - -Some text directly under a heading. - -###### H6 Heading with \`code\` - -Some text directly under a heading. - ---- - -# H1 Heading - -\`\`\`tsx -import { useEffect, useState } from "react"; - -type User = { - id: string; - name: string; -}; - -export default function UsersList() { - const [users, setUsers] = useState([]); +export default function Page() { + const router = useRouter(); useEffect(() => { - fetch("/api/users") - .then((res) => res.json()) - .then(setUsers); - }, []); + router.replace(`/styles/${nanoid()}`); + }, [router]); return ( -
    - {users.map((user) => ( -
  • {user.name}
  • - ))} -
- ); -} -\`\`\` - -- A list item -- A list item -- A list item - -\`\`\`bash -# A multi-line shell script example -set -e - -echo "Starting server..." -bun run dev - -echo "Done!" -\`\`\` - -## H2 Heading - -\`\`\`json -{ - "name": "my-app", - "version": "1.0.0", - "scripts": { - "dev": "bun run dev", - "build": "bun run build" - }, - "dependencies": { - "react": "^18.0.0", - "next": "^14.0.0" - } -} -\`\`\` - -> A blockquote - -\`\`\`css -.prose { - max-width: 65ch; - line-height: 1.75; - font-feature-settings: "liga" 1; -} -\`\`\` - -### H3 Heading - -\`\`\`html - - - - - Example - - -

Hello, world!

- - -\`\`\` - -Some text between two code blocks. - -\`\`\`css -.prose { - max-width: 65ch; - line-height: 1.75; - font-feature-settings: "liga" 1; -} -\`\`\` - -#### H4 Heading - -\`\`\`json -{ - "name": "my-app", - "version": "1.0.0", - "scripts": { - "dev": "bun run dev", - "build": "bun run build" - }, - "dependencies": { - "react": "^18.0.0", - "next": "^14.0.0" - } -} -\`\`\` - -##### H5 Heading - -\`\`\`tsx -
-

Hello, world!

-
-\`\`\` - -###### H6 Heading - -\`\`\` -A code block without any language -Just some plain text -\`\`\` - ---- - -| Syntax | Description | -| --------- | ----------- | -| Header | Title | -| Paragraph | Text | - -| Feature | Example | Notes | -| ------------- | ------------------------------------ | ------------------------- | -| Link | [Liveblocks](https://liveblocks.io/) | External link | -| Inline code | \`const x = 42;\` | Code inside table | -| Bold text | **Important** | Styling test | -| Italic text | _Emphasis_ | Test italic inside tables | -| Strikethrough | ~~Deprecated~~ | Show removal | - ---- - -> ### Quoted Heading -> -> - A quoted unordered list item -> - Another quoted unordered list item -> -> 1. A quoted ordered list item -> 2. Another quoted ordered list item -> -> \`\`\`ts -> const x = 1; -> \`\`\` - -> Outer quote -> -> > Inner quote -> > -> > - With list -> > - And \`code\` - ---- - -1. A numbered list item -- A "nested" list item -- Another "nested" list item - -2. Another numbered list item -- A "nested" list item -- Another "nested" list item - -3. Yet another numbered list item -- A "nested" list item -- Another "nested" list item - ---- - -1. A numbered list item - -\`\`\` -const a = 2; -\`\`\` - -2. Another numbered list item - -> A quote. - -3. Yet another numbered list item - -A paragraph. - ---- - -1. A numbered list item -1. Another numbered list item -1. Yet another numbered list item - ---- - -The abbreviation for HyperText Markup Language is HTML. - -Press Ctrl + C to copy. - -This is highlighted text. - -E = mc2 -`; - -const lineBreakMessage = ` -A message in a single paragraph with line breaks - -This is a new line - -This is another line -`; - -function useIsMounted() { - const isMounted = useSyncExternalStore( - () => () => {}, - () => true, - () => false - ); - - return isMounted; -} - -function random(min: number, max: number) { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -export default function Home() { - const isMounted = useIsMounted(); - - const [isReasoning, setReasoning] = useState(true); - const [isStreaming, setStreaming] = useState(false); - const [streamedMarkdownMessage, setStreamedMarkdownMessage] = useState(""); - const intervalRef = useRef | null>(null); - - useEffect(() => { - // Reset and clear interval when toggling - setStreamedMarkdownMessage(""); - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - - if (!isStreaming) { - return; - } - - let index = 0; - - intervalRef.current = setInterval(() => { - index += random(4, 10); - - setStreamedMarkdownMessage(markdownMessage.slice(0, index)); - - if (index >= markdownMessage.length && intervalRef.current) { - clearInterval(intervalRef.current); - } - }, 40); - - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - }; - }, [isStreaming]); - - if (!isMounted) { - return null; - } - - return ( -
-
-
-
-
-
- This message has been deleted. -
-
-
-
- This message has been deleted. -
-
- -
-
-
-

Some errors

-
-
-
-
-
{/* Empty */}
-
- - - - An error message with no content -
-
-
-
- -
-
- - - - A long error message that wraps across multiple lines and keeps - going forever, with a link to the docs too. - https://liveblocks.io/docs/errors/cross-linked -
-
- -
-
-
-

A pending message

-
-
-
-
-
- Thinking… -
-
-
-
- Thinking but with a longer message to see how flexible the - shimmer is… -
-
- -
-
-
-

A reasoning message with tools

-
-
-
-
-
- - -
-
- A rendered tool -
-
-
-
- A second rendered tool immediately after the first one -
-
- -
-
- A rendered tool as the last part -
-
-
-
- -
-
-
-

A Markdown message

-
-
-
-
-
- -
-
- -
-
-
-

A message with line breaks

-
-
-
-
-
-

{lineBreakMessage}

-
-
-
-
-
-
- {/* Debug panel */} -
- - - -
-
-
-
-
+
+
Loading...
+
); -} +} \ No newline at end of file diff --git a/e2e/next-ai-kitchen-sink/app/todo/[chatId]/page.tsx b/e2e/next-ai-kitchen-sink/app/todo/[chatId]/page.tsx new file mode 100644 index 00000000000..acefbac4cec --- /dev/null +++ b/e2e/next-ai-kitchen-sink/app/todo/[chatId]/page.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { use } from "react"; +import { defineAiTool } from "@liveblocks/core"; +import { + ClientSideSuspense, + LiveblocksProvider, + RegisterAiKnowledge, + useSendAiMessage, +} from "@liveblocks/react/suspense"; +import { useCallback, useState } from "react"; +import { Popover } from "radix-ui"; +import { + AiChat, + AiChatComponentsEmptyProps, + AiTool, +} from "@liveblocks/react-ui"; + +export default function Page({ params }: { params: Promise<{ chatId: string }> }) { + const { chatId } = use(params); + const [todos, setTodos] = useState< + { id: number; title: string; isCompleted: boolean }[] + >([ + { + id: 9823, + title: "Get groceries", + isCompleted: true, + }, + { + id: 72, + title: "Go to the gym", + isCompleted: false, + }, + { + id: 1313, + title: "Cook dinner", + isCompleted: false, + }, + ]); + const [value, setValue] = useState(""); + + const toggleTodo = useCallback((id: number) => { + setTodos((todos) => + todos.map((todo) => { + if (todo.id === id) { + return { ...todo, isCompleted: !todo.isCompleted }; + } + return todo; + }) + ); + }, []); + + const addTodo = useCallback((title: string) => { + setTodos((todos) => [ + ...todos, + { + id: Math.floor(Math.random() * 10000), + title, + isCompleted: false, + }, + ]); + }, []); + + const deleteTodos = useCallback((ids: number[]) => { + setTodos((todos) => todos.filter((todo) => !ids.includes(todo.id))); + }, []); + + return ( +
+
+ { + setValue(e.currentTarget.value); + }} + onKeyDown={(e) => { + if (value.length > 0 && e.key === "Enter") { + addTodo(value); + setValue(""); + } + }} + className="shadow-[0_0_0_1px_rgb(0_0_0_/_4%),_0_2px_6px_rgb(0_0_0_/_4%),_0_8px_26px_rgb(0_0_0_/_6%)] rounded-lg p-3 w-full border-0 mb-4" + /> + + {todos.length > 0 && ( +
    + {todos.map((todo, index) => { + return ( +
  • toggleTodo(todo.id)} + > + {todo.title} +
  • + ); + })} +
+ )} +
+ +
+ + + + + + + + + + + { + const { ids } = args; + if (ids.length === 0) { + return { data: { todos } }; + } else { + return { + data: { + todos: todos.filter((t) => ids.includes(t.id)), + }, + }; + } + }, + }), + + addTodos: defineAiTool()({ + description: "Add a new todo item to the list", + parameters: { + type: "object", + properties: { + titles: { + type: "array", + description: + "The titles of the new items to add to the list", + items: { type: "string" }, + }, + }, + required: ["titles"], + additionalProperties: false, + }, + execute: ({ titles }) => { + for (const title of titles) { + addTodo(title); + } + }, + }), + + toggleTodo: defineAiTool()({ + description: "Toggle a todo's completion status", + parameters: { + type: "object", + properties: { + id: { + description: "The id of the todo to toggle", + type: "number", + }, + }, + required: ["id"], + additionalProperties: false, + }, + execute: ({ id }) => { + toggleTodo(id); + }, + render: () => ( + + + + ), + }), + + deleteTodos: defineAiTool<{ deletedTitles: string[] }>()({ + description: "Deletes one or more todo items by ID", + parameters: { + type: "object", + properties: { + ids: { + description: "The ids of the todo item to delete", + type: "array", + items: { type: "number" }, + }, + }, + required: ["ids"], + additionalProperties: false, + }, + + // TODO Add followUp: true here later? + render: ({ result, types }) => ( + + { + const deletedTitles = todos + .filter((t) => ids.includes(t.id)) + .map((todo) => todo.title); + + deleteTodos(ids); + return { data: { deletedTitles } }; + }} + > + Okay to delete? + + + {result?.data ? ( +
+ Deleted: +
    + {result.data.deletedTitles?.map( + (title, i: number) => ( +
  • {title}
  • + ) + )} +
+
+ ) : result?.cancelled ? ( +
The request was denied
+ ) : null} +
+ ), + }), + }} + className="rounded-xl" + /> + + +
+
+
+
+
+
+
+ ); +} + +const CHAT_SUGGESTIONS = [ + { + label: "List all todos", + message: "List all my todos", + }, + { + label: "Add new todo", + message: "Add a todo titled 'Buy milk'", + }, + { + label: "List completed todos", + message: "List all completed todos", + }, + { + label: "Delete todos", + message: "Delete the second and third todos", + }, +]; + +function AiChatEmptyComponent({ chatId }: AiChatComponentsEmptyProps) { + const sendMessage = useSendAiMessage(chatId); + + return ( +
+

How can I help you?

+ + {/* Suggestion Tags */} +
+ {CHAT_SUGGESTIONS.map(({ label, message }) => ( + + ))} +
+
+ ); +} diff --git a/e2e/next-ai-kitchen-sink/app/todo/page.tsx b/e2e/next-ai-kitchen-sink/app/todo/page.tsx index 99537b0b533..2a61e12f7e8 100644 --- a/e2e/next-ai-kitchen-sink/app/todo/page.tsx +++ b/e2e/next-ai-kitchen-sink/app/todo/page.tsx @@ -1,320 +1,20 @@ "use client"; -import { defineAiTool } from "@liveblocks/core"; -import { - ClientSideSuspense, - LiveblocksProvider, - RegisterAiKnowledge, - useSendAiMessage, -} from "@liveblocks/react/suspense"; -import { useCallback, useState } from "react"; -import { Popover } from "radix-ui"; -import { - AiChat, - AiChatComponentsEmptyProps, - AiTool, -} from "@liveblocks/react-ui"; +import { nanoid } from "@liveblocks/core"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; export default function Page() { - const [todos, setTodos] = useState< - { id: number; title: string; isCompleted: boolean }[] - >([ - { - id: 9823, - title: "Get groceries", - isCompleted: true, - }, - { - id: 72, - title: "Go to the gym", - isCompleted: false, - }, - { - id: 1313, - title: "Cook dinner", - isCompleted: false, - }, - ]); - const [value, setValue] = useState(""); + const router = useRouter(); - const toggleTodo = useCallback((id: number) => { - setTodos((todos) => - todos.map((todo) => { - if (todo.id === id) { - return { ...todo, isCompleted: !todo.isCompleted }; - } - return todo; - }) - ); - }, []); - - const addTodo = useCallback((title: string) => { - setTodos((todos) => [ - ...todos, - { - id: Math.floor(Math.random() * 10000), - title, - isCompleted: false, - }, - ]); - }, []); - - const deleteTodos = useCallback((ids: number[]) => { - setTodos((todos) => todos.filter((todo) => !ids.includes(todo.id))); - }, []); + useEffect(() => { + router.replace(`/todo/${nanoid()}`); + }, [router]); + // Show loading while redirecting return ( -
-
- { - setValue(e.currentTarget.value); - }} - onKeyDown={(e) => { - if (value.length > 0 && e.key === "Enter") { - addTodo(value); - setValue(""); - } - }} - className="shadow-[0_0_0_1px_rgb(0_0_0_/_4%),_0_2px_6px_rgb(0_0_0_/_4%),_0_8px_26px_rgb(0_0_0_/_6%)] rounded-lg p-3 w-full border-0 mb-4" - /> - - {todos.length > 0 && ( -
    - {todos.map((todo, index) => { - return ( -
  • toggleTodo(todo.id)} - > - {todo.title} -
  • - ); - })} -
- )} -
- -
- - - - - - - - - - - { - const { ids } = args; - if (ids.length === 0) { - return { data: { todos } }; - } else { - return { - data: { - todos: todos.filter((t) => ids.includes(t.id)), - }, - }; - } - }, - }), - - addTodos: defineAiTool()({ - description: "Add a new todo item to the list", - parameters: { - type: "object", - properties: { - titles: { - type: "array", - description: - "The titles of the new items to add to the list", - items: { type: "string" }, - }, - }, - required: ["titles"], - additionalProperties: false, - }, - execute: ({ titles }) => { - for (const title of titles) { - addTodo(title); - } - }, - }), - - toggleTodo: defineAiTool()({ - description: "Toggle a todo's completion status", - parameters: { - type: "object", - properties: { - id: { - description: "The id of the todo to toggle", - type: "number", - }, - }, - required: ["id"], - additionalProperties: false, - }, - execute: ({ id }) => { - toggleTodo(id); - }, - render: () => ( - - - - ), - }), - - deleteTodos: defineAiTool<{ deletedTitles: string[] }>()({ - description: "Deletes one or more todo items by ID", - parameters: { - type: "object", - properties: { - ids: { - description: "The ids of the todo item to delete", - type: "array", - items: { type: "number" }, - }, - }, - required: ["ids"], - additionalProperties: false, - }, - - // TODO Add followUp: true here later? - render: ({ result, types }) => ( - - { - const deletedTitles = todos - .filter((t) => ids.includes(t.id)) - .map((todo) => todo.title); - - deleteTodos(ids); - return { data: { deletedTitles } }; - }} - > - Okay to delete? - - - {result?.data ? ( -
- Deleted: -
    - {result.data.deletedTitles?.map( - (title, i: number) => ( -
  • {title}
  • - ) - )} -
-
- ) : result?.cancelled ? ( -
The request was denied
- ) : null} -
- ), - }), - }} - className="rounded-xl" - /> - - -
-
-
-
-
-
-
- ); -} - -const CHAT_SUGGESTIONS = [ - { - label: "List all todos", - message: "List all my todos", - }, - { - label: "Add new todo", - message: "Add a todo titled 'Buy milk'", - }, - { - label: "List completed todos", - message: "List all completed todos", - }, - { - label: "Delete todos", - message: "Delete the second and third todos", - }, -]; - -function AiChatEmptyComponent({ chatId }: AiChatComponentsEmptyProps) { - const sendMessage = useSendAiMessage(chatId); - - return ( -
-

How can I help you?

- - {/* Suggestion Tags */} -
- {CHAT_SUGGESTIONS.map(({ label, message }) => ( - - ))} -
+
+
Loading...
); -} +} \ No newline at end of file diff --git a/e2e/next-ai-kitchen-sink/app/toolcall-streaming/[chatId]/page.tsx b/e2e/next-ai-kitchen-sink/app/toolcall-streaming/[chatId]/page.tsx new file mode 100644 index 00000000000..67318705528 --- /dev/null +++ b/e2e/next-ai-kitchen-sink/app/toolcall-streaming/[chatId]/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { use } from "react"; +import { defineAiTool } from "@liveblocks/core"; +import { + ClientSideSuspense, + LiveblocksProvider, + useSendAiMessage, +} from "@liveblocks/react/suspense"; +import { + AiChat, + AiChatComponentsEmptyProps, + AiTool, +} from "@liveblocks/react-ui"; + +export default function HtmlStreamingPage({ params }: { params: Promise<{ chatId: string }> }) { + const { chatId } = use(params); + return ( +
+ + + { + return { data: { success: true } }; + }, + render: () => { + return ( + + + + ); + }, + }), + }} + className="h-screen" + /> + + +
+ ); +} + +const CHAT_SUGGESTIONS = [ + { + label: "Simple marketing page", + message: "Create a simple marketing page for a SaaS product", + }, + { + label: "Landing page with hero", + message: + "Generate a landing page with a hero section, features, and footer", + }, + { + label: "Product showcase", + message: + "Create an HTML page showcasing a new product with images and descriptions", + }, + { + label: "Contact page", + message: "Build a contact page with a form and company information", + }, +]; + +function AiChatEmptyComponent({ chatId }: AiChatComponentsEmptyProps) { + const sendMessage = useSendAiMessage(chatId); + + return ( +
+

HTML Streaming Test

+

+ Ask me to generate HTML and watch it stream in real-time! +

+ + {/* Suggestion Tags */} +
+ {CHAT_SUGGESTIONS.map(({ label, message }) => ( + + ))} +
+
+ ); +} diff --git a/e2e/next-ai-kitchen-sink/app/toolcall-streaming/page.tsx b/e2e/next-ai-kitchen-sink/app/toolcall-streaming/page.tsx new file mode 100644 index 00000000000..00be891a8af --- /dev/null +++ b/e2e/next-ai-kitchen-sink/app/toolcall-streaming/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { nanoid } from "@liveblocks/core"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function Page() { + const router = useRouter(); + + useEffect(() => { + router.replace(`/toolcall-streaming/${nanoid()}`); + }, [router]); + + return ( +
+
Loading...
+
+ ); +} \ No newline at end of file diff --git a/e2e/next-ai-kitchen-sink/package.json b/e2e/next-ai-kitchen-sink/package.json index faa07a4254d..9352fc7e090 100644 --- a/e2e/next-ai-kitchen-sink/package.json +++ b/e2e/next-ai-kitchen-sink/package.json @@ -27,6 +27,7 @@ "@tailwindcss/postcss": "^4", "eslint": "^9", "eslint-config-next": "15.3.1", + "playwright": "^1.54.2", "tailwindcss": "^4", "typescript": "^5" } diff --git a/e2e/next-ai-kitchen-sink/playwright.config.ts b/e2e/next-ai-kitchen-sink/playwright.config.ts index 0a678a3d082..2b6e4bfd44a 100644 --- a/e2e/next-ai-kitchen-sink/playwright.config.ts +++ b/e2e/next-ai-kitchen-sink/playwright.config.ts @@ -1,5 +1,4 @@ -import type { PlaywrightTestConfig } from "@playwright/test"; -import { devices } from "@playwright/test"; +import { defineConfig, devices } from "@playwright/test"; /** * Read environment variables from file. @@ -10,7 +9,7 @@ import { devices } from "@playwright/test"; /** * See https://playwright.dev/docs/test-configuration. */ -const config: PlaywrightTestConfig = { +export default defineConfig({ testDir: "./test", /* Maximum time one test can run for. */ timeout: 60 * 1000, @@ -27,27 +26,37 @@ const config: PlaywrightTestConfig = { retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 4 : 6, + /* Fully parallel test execution */ + fullyParallel: true, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: "html", + reporter: [ + ["html", { title: "Liveblocks AI Kitchen Sink E2E Tests" }], + ["github"], + ], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { headless: process.env.CI || process.env.HEADLESS ? true : false, viewport: { width: 640, height: 800 }, permissions: ["clipboard-write", "clipboard-read"], - /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ - actionTimeout: 0, + /* Maximum time each action such as `click()` can take. 10s local, 15s CI. */ + actionTimeout: process.env.CI ? 15000 : 10000, /* Base URL to use in actions like `await page.goto('/')`. */ // baseURL: 'http://localhost:3000', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: "retain-on-failure", + trace: "on-first-retry", + video: "on-first-retry", + screenshot: "on-first-failure", // Only captures main page }, /* Configure projects for major browsers */ projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] }, + use: { + ...devices["Desktop Chrome"], + channel: "chromium", // Enable the use of New Headless mode in Chromium + }, }, // { // name: "firefox", @@ -65,7 +74,6 @@ const config: PlaywrightTestConfig = { : "npm run dev", // Test dev builds on CI (with React StrictMode enabled) port: 3008, // AI kitchen sink port reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, }, -}; - -export default config; +}); diff --git a/e2e/next-ai-kitchen-sink/test/knowledge-isolation.test.ts b/e2e/next-ai-kitchen-sink/test/knowledge-isolation.test.ts index e3e33ad12a7..17c98ed86db 100644 --- a/e2e/next-ai-kitchen-sink/test/knowledge-isolation.test.ts +++ b/e2e/next-ai-kitchen-sink/test/knowledge-isolation.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "@playwright/test"; import type { Page } from "@playwright/test"; +import { createRandomChat, cleanupAllChats } from "./test-helpers"; async function setupChatA(page: Page) { const textInput = page.locator( @@ -10,10 +11,10 @@ async function setupChatA(page: Page) { ); // If there's an ongoing operation (abort button is enabled), click it first to clear state - if ((await sendButton.getAttribute("data-variant")) === "secondary") { + if ((await sendButton.getAttribute("aria-label")) === "Abort response") { await sendButton.click(); // Wait for it to return to send state - await expect(sendButton).toHaveAttribute("data-variant", "primary"); + await expect(sendButton).toHaveAttribute("aria-label", "Send"); } // Clear any existing text @@ -31,10 +32,10 @@ async function setupChatB(page: Page) { ); // If there's an ongoing operation (abort button is enabled), click it first to clear state - if ((await sendButton.getAttribute("data-variant")) === "secondary") { + if ((await sendButton.getAttribute("aria-label")) === "Abort response") { await sendButton.click(); // Wait for it to return to send state - await expect(sendButton).toHaveAttribute("data-variant", "primary"); + await expect(sendButton).toHaveAttribute("aria-label", "Send"); } // Clear any existing text @@ -55,11 +56,11 @@ async function sendMessageToChatA(page: Page, message: string) { ).toBeVisible(); // Wait for generation to finish by monitoring button state change - // Button should become "secondary" (abort) during generation, then back to "primary" (send) - await expect(sendButton).toHaveAttribute("data-variant", "secondary", { + // Button should become "Abort response" during generation, then back to "Send" + await expect(sendButton).toHaveAttribute("aria-label", "Abort response", { timeout: 10000, }); - await expect(sendButton).toHaveAttribute("data-variant", "primary", { + await expect(sendButton).toHaveAttribute("aria-label", "Send", { timeout: 60000, }); } @@ -76,47 +77,31 @@ async function sendMessageToChatB(page: Page, message: string) { ).toBeVisible(); // Wait for generation to finish by monitoring button state change - // Button should become "secondary" (abort) during generation, then back to "primary" (send) - await expect(sendButton).toHaveAttribute("data-variant", "secondary", { + // Button should become "Abort response" during generation, then back to "Send" + await expect(sendButton).toHaveAttribute("aria-label", "Abort response", { timeout: 10000, }); - await expect(sendButton).toHaveAttribute("data-variant", "primary", { + await expect(sendButton).toHaveAttribute("aria-label", "Send", { timeout: 60000, }); } test.describe("AiChat Knowledge Isolation", () => { + test.afterEach(async () => { + await cleanupAllChats(); + }); + test.beforeEach(async ({ page }) => { - // Clean up existing chats - await page.goto("/chats"); - await expect(page.locator("h1")).toHaveText("List of all chats"); - - // Delete chat-a if it exists - const chatALink = page.locator('a[href="/chats/chat-a"]'); - if (await chatALink.isVisible()) { - const deleteButton = chatALink - .locator("..") - .locator('button:has-text("Delete")'); - await deleteButton.click(); - await expect(chatALink).not.toBeVisible(); - } - - // Delete chat-b if it exists - const chatBLink = page.locator('a[href="/chats/chat-b"]'); - if (await chatBLink.isVisible()) { - const deleteButton = chatBLink - .locator("..") - .locator('button:has-text("Delete")'); - await deleteButton.click(); - await expect(chatBLink).not.toBeVisible(); - } + // Note: Each test will create its own unique chat IDs to avoid conflicts }); test("should NOT share knowledge between separate AiChat instances", async ({ page, }) => { - // Go to the dual chat page - await page.goto("/dual-chat"); + // Create unique test chat IDs and go to the dual chat page + const chatId1 = createRandomChat(page); + const chatId2 = createRandomChat(page); + await page.goto(`/dual-chat/${chatId1}/${chatId2}`); // Wait for the page title and both chats to be visible await expect( @@ -163,7 +148,11 @@ test.describe("AiChat Knowledge Isolation", () => { test("should isolate different knowledge values between chats", async ({ page, }) => { - await page.goto("/dual-chat"); + // Create unique test chat IDs + const chatId1 = createRandomChat(page); + const chatId2 = createRandomChat(page); + + await page.goto(`/dual-chat/${chatId1}/${chatId2}`); // Wait for the page title to be visible await expect( @@ -212,8 +201,10 @@ test.describe("AiChat Knowledge Isolation", () => { test("should SHARE knowledge when using RegisterAiKnowledge globally", async ({ page, }) => { - // Go to the dual chat page (now contains both local and global knowledge testing) - await page.goto("/dual-chat"); + // Create unique test chat IDs and go to the dual chat page + const chatId1 = createRandomChat(page); + const chatId2 = createRandomChat(page); + await page.goto(`/dual-chat/${chatId1}/${chatId2}`); // Wait for the page title and both chats to be visible await expect( diff --git a/e2e/next-ai-kitchen-sink/test/knowledge-simple.test.ts b/e2e/next-ai-kitchen-sink/test/knowledge-simple.test.ts index f3bf3b82f91..3eb9bbaeddf 100644 --- a/e2e/next-ai-kitchen-sink/test/knowledge-simple.test.ts +++ b/e2e/next-ai-kitchen-sink/test/knowledge-simple.test.ts @@ -1,24 +1,17 @@ import { test, expect } from "@playwright/test"; +import { createRandomChat, cleanupAllChats } from "./test-helpers"; test.describe("Knowledge Registration - Simple", () => { + test.afterEach(async () => { + await cleanupAllChats(); + }); + test("should open knowledge page and interact with AI chat", async ({ page, }) => { - // Clean up first - delete the knowledge chat if it exists - await page.goto("/chats"); - await expect(page.locator("h1")).toHaveText("List of all chats"); - - const knowledgeChatLink = page.locator('a[href="/chats/todo125"]'); - if (await knowledgeChatLink.isVisible()) { - const deleteButton = knowledgeChatLink - .locator("..") - .locator('button:has-text("Delete")'); - await deleteButton.click(); - await expect(knowledgeChatLink).not.toBeVisible(); - } - - // Go to knowledge page - await page.goto("/knowledge"); + // Create unique test chat and go to knowledge page + const chatId = createRandomChat(page); + await page.goto(`/knowledge/${chatId}`); // Wait for the page to load - verify default tab is "Todo app" await expect( @@ -51,11 +44,11 @@ test.describe("Knowledge Registration - Simple", () => { const sendButton = page.locator(".lb-ai-composer-action"); // If there's an ongoing operation (abort button is enabled), click it first to clear state - const buttonVariant = await sendButton.getAttribute("data-variant"); - if (buttonVariant === "secondary") { + const buttonLabel = await sendButton.getAttribute("aria-label"); + if (buttonLabel === "Abort response") { await sendButton.click(); // Wait for it to return to send state - await expect(sendButton).toHaveAttribute("data-variant", "primary"); + await expect(sendButton).toHaveAttribute("aria-label", "Send"); } // Clear any existing text and send a simple message diff --git a/e2e/next-ai-kitchen-sink/test/knowledge.test.ts b/e2e/next-ai-kitchen-sink/test/knowledge.test.ts index 906a575d791..3c2f7d2b6eb 100644 --- a/e2e/next-ai-kitchen-sink/test/knowledge.test.ts +++ b/e2e/next-ai-kitchen-sink/test/knowledge.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "@playwright/test"; import type { Page } from "@playwright/test"; +import { createRandomChat, cleanupAllChats } from "./test-helpers"; async function setupAiChat(page: Page) { // The knowledge page has the chat trigger button with test ID @@ -62,24 +63,16 @@ async function sendAiMessage(page: Page, message: string) { } test.describe("Knowledge Registration", () => { + test.afterEach(async () => { + await cleanupAllChats(); + }); + test("should use registered knowledge about current view and todos", async ({ page, }) => { - // Clean up - delete the knowledge chat to start fresh - await page.goto("/chats"); - await expect(page.locator("h1")).toHaveText("List of all chats"); - - const knowledgeChatLink = page.locator('a[href="/chats/todo125"]'); - if (await knowledgeChatLink.isVisible()) { - const deleteButton = knowledgeChatLink - .locator("..") - .locator('button:has-text("Delete")'); - await deleteButton.click(); - await expect(knowledgeChatLink).not.toBeVisible(); - } - - // Go to knowledge page - await page.goto("/knowledge"); + // Create unique test chat and go to knowledge page + const chatId = createRandomChat(page); + await page.goto(`/knowledge/${chatId}`); // Wait for the page to load - verify default tab is "Todo app" await expect( @@ -111,18 +104,9 @@ test.describe("Knowledge Registration", () => { }); test("should use nickname knowledge when enabled", async ({ page }) => { - // Clean up first - await page.goto("/chats"); - const knowledgeChatLink = page.locator('a[href="/chats/todo125"]'); - if (await knowledgeChatLink.isVisible()) { - const deleteButton = knowledgeChatLink - .locator("..") - .locator('button:has-text("Delete")'); - await deleteButton.click(); - await expect(knowledgeChatLink).not.toBeVisible(); - } - - await page.goto("/knowledge"); + // Create unique test chat and go to knowledge page + const chatId = createRandomChat(page); + await page.goto(`/knowledge/${chatId}`); // Find the nickname checkbox specifically by its label text const nicknameCheckbox = page.locator( @@ -152,18 +136,9 @@ test.describe("Knowledge Registration", () => { }); test("should use dark mode knowledge and tool", async ({ page }) => { - // Clean up first - await page.goto("/chats"); - const knowledgeChatLink = page.locator('a[href="/chats/todo125"]'); - if (await knowledgeChatLink.isVisible()) { - const deleteButton = knowledgeChatLink - .locator("..") - .locator('button:has-text("Delete")'); - await deleteButton.click(); - await expect(knowledgeChatLink).not.toBeVisible(); - } - - await page.goto("/knowledge"); + // Create unique test chat and go to knowledge page + const chatId = createRandomChat(page); + await page.goto(`/knowledge/${chatId}`); // Find the expose dark mode checkbox by its label text const exposeCheckbox = page.locator( @@ -211,18 +186,9 @@ test.describe("Knowledge Registration", () => { test("should update knowledge when switching between tabs", async ({ page, }) => { - // Clean up first - await page.goto("/chats"); - const knowledgeChatLink = page.locator('a[href="/chats/todo125"]'); - if (await knowledgeChatLink.isVisible()) { - const deleteButton = knowledgeChatLink - .locator("..") - .locator('button:has-text("Delete")'); - await deleteButton.click(); - await expect(knowledgeChatLink).not.toBeVisible(); - } - - await page.goto("/knowledge"); + // Create unique test chat and go to knowledge page + const chatId = createRandomChat(page); + await page.goto(`/knowledge/${chatId}`); // Start on Todo app tab await expect(page.getByTestId("tab-todo-app")).toHaveClass(/font-bold/); @@ -259,18 +225,9 @@ test.describe("Knowledge Registration", () => { }); test("should handle knowledge being disabled", async ({ page }) => { - // Clean up first - await page.goto("/chats"); - const knowledgeChatLink = page.locator('a[href="/chats/todo125"]'); - if (await knowledgeChatLink.isVisible()) { - const deleteButton = knowledgeChatLink - .locator("..") - .locator('button:has-text("Delete")'); - await deleteButton.click(); - await expect(knowledgeChatLink).not.toBeVisible(); - } - - await page.goto("/knowledge"); + // Create unique test chat and go to knowledge page + const chatId = createRandomChat(page); + await page.goto(`/knowledge/${chatId}`); // Find and disable dark mode knowledge exposure const exposeCheckbox = page.getByTestId("expose-dark-mode-checkbox"); diff --git a/e2e/next-ai-kitchen-sink/test/simple-chat.test.ts b/e2e/next-ai-kitchen-sink/test/simple-chat.test.ts index c0ddb214695..0c7cbd80de0 100644 --- a/e2e/next-ai-kitchen-sink/test/simple-chat.test.ts +++ b/e2e/next-ai-kitchen-sink/test/simple-chat.test.ts @@ -1,8 +1,9 @@ import { test, expect } from "@playwright/test"; import type { Page } from "@playwright/test"; +import { createRandomChat, cleanupAllChats } from "./test-helpers"; -async function setupSimpleChat(page: Page) { - await page.goto("/simple"); +async function setupSimpleChat(page: Page, chatId: string) { + await page.goto(`/simple/${chatId}`); // Wait for the chat interface to load await expect(page.locator(".lb-ai-composer")).toBeVisible(); @@ -30,24 +31,13 @@ async function setupSimpleChat(page: Page) { } test.describe("Simple Chat", () => { - test.beforeEach(async ({ page }) => { - // Clean up - delete the "ai-chat" chat to start fresh - await page.goto("/chats"); - await expect(page.locator("h1")).toHaveText("List of all chats"); - - // Look for the "ai-chat" chat and delete it if it exists - const aiChatLink = page.locator('a[href="/chats/ai-chat"]'); - if (await aiChatLink.isVisible()) { - const deleteButton = aiChatLink - .locator("..") - .locator('button:has-text("Delete")'); - await deleteButton.click(); - await expect(aiChatLink).not.toBeVisible(); - } + test.afterEach(async () => { + await cleanupAllChats(); }); test("should handle ping-pong interaction", async ({ page }) => { - const { textInput, sendButton } = await setupSimpleChat(page); + const chatId = createRandomChat(page); + const { textInput, sendButton } = await setupSimpleChat(page, chatId); // Perform the ping-pong interaction await textInput.fill("ping"); @@ -78,7 +68,8 @@ test.describe("Simple Chat", () => { test("should abort AI response when abort button is clicked", async ({ page, }) => { - const { textInput, sendButton } = await setupSimpleChat(page); + const chatId = createRandomChat(page); + const { textInput, sendButton } = await setupSimpleChat(page, chatId); // Ask a question that should generate a long response await textInput.fill( diff --git a/e2e/next-ai-kitchen-sink/test/test-helpers.ts b/e2e/next-ai-kitchen-sink/test/test-helpers.ts new file mode 100644 index 00000000000..c79601eac2f --- /dev/null +++ b/e2e/next-ai-kitchen-sink/test/test-helpers.ts @@ -0,0 +1,44 @@ +import { expect, type Page } from "@playwright/test"; +import { nanoid } from "@liveblocks/core"; + +// Store cleanup functions per test - using a simple array since we can't use test.info() +const registry: Array<() => Promise> = []; + +/** + * Creates a unique random chat ID with date prefix and registers cleanup. + * Call `cleanupAllChats()` in your test's try/finally block or afterEach hook. + */ +export function createRandomChat(page: Page): string { + const today = new Date().toISOString().slice(0, 10).replace(/-/g, ""); // YYYYMMDD + const chatId = `${today}-${nanoid(8)}`; // Use shorter 8-char nanoid + + const cleanupFn = async () => { + try { + await page.goto(`/cleanup/${chatId}`); + // Wait for the deletion to complete - should show "Chat deleted" on success + await expect(page.locator("text=Chat deleted")).toBeVisible({ + timeout: 10000, + }); + } catch (error) { + console.warn(`Failed to cleanup chat ${chatId}:`, error); + } + }; + + registry.push(cleanupFn); + return chatId; +} + +/** + * Cleans up all chats created with createRandomChat(). + * Call this in your test.afterEach() hook. + */ +export async function cleanupAllChats(): Promise { + let cleanupFn: (() => Promise) | undefined; + while ((cleanupFn = registry.pop())) { + try { + await cleanupFn(); + } catch (error) { + console.warn("Failed to cleanup chat:", error); + } + } +} diff --git a/e2e/next-ai-kitchen-sink/test/tool-calling.test.ts b/e2e/next-ai-kitchen-sink/test/tool-calling.test.ts index 8dd4793a7fc..b7d558e4e01 100644 --- a/e2e/next-ai-kitchen-sink/test/tool-calling.test.ts +++ b/e2e/next-ai-kitchen-sink/test/tool-calling.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "@playwright/test"; import type { Page } from "@playwright/test"; +import { createRandomChat, cleanupAllChats } from "./test-helpers"; async function setupAiChat(page: Page) { // Wait for the AI chat popover to be visible @@ -40,24 +41,14 @@ async function sendAiMessage(page: Page, message: string) { } test.describe("Tool Calling", () => { - test("should perform todo operations via AI tool calls", async ({ page }) => { - // Step 1: Clean up - delete the "todo" chat to start fresh - await page.goto("/chats"); - await expect(page.locator("h1")).toHaveText("List of all chats"); - - // Look for the "todo" chat and delete it if it exists - const todoChatLink = page.locator('a[href="/chats/todo"]'); - if (await todoChatLink.isVisible()) { - const deleteButton = todoChatLink - .locator("..") - .locator('button:has-text("Delete")'); - await deleteButton.click(); - // Wait for it to be deleted - await expect(todoChatLink).not.toBeVisible(); - } + test.afterEach(async () => { + await cleanupAllChats(); + }); - // Step 2: Go to the todo page - await page.goto("/todo"); + test("should perform todo operations via AI tool calls", async ({ page }) => { + // Create unique test chat and go to todo page + const chatId = createRandomChat(page); + await page.goto(`/todo/${chatId}`); // Wait for the page to load and show default todos await expect(page.locator('li:has-text("Get groceries")')).toBeVisible(); @@ -148,18 +139,9 @@ test.describe("Tool Calling", () => { }); test("should handle tool call errors gracefully", async ({ page }) => { - // Clean up first - await page.goto("/chats"); - const todoChatLink = page.locator('a[href="/chats/todo"]'); - if (await todoChatLink.isVisible()) { - const deleteButton = todoChatLink - .locator("..") - .locator('button:has-text("Delete")'); - await deleteButton.click(); - await expect(todoChatLink).not.toBeVisible(); - } - - await page.goto("/todo"); + // Create unique test chat and go to todo page + const chatId = createRandomChat(page); + await page.goto(`/todo/${chatId}`); // Try to toggle a non-existent todo await sendAiMessage(page, "Toggle the todo with ID 99999"); @@ -183,18 +165,10 @@ test.describe("Tool Calling", () => { test("should handle cancellation of delete confirmation", async ({ page, }) => { - // Clean up first - await page.goto("/chats"); - const todoChatLink = page.locator('a[href="/chats/todo"]'); - if (await todoChatLink.isVisible()) { - const deleteButton = todoChatLink - .locator("..") - .locator('button:has-text("Delete")'); - await deleteButton.click(); - await expect(todoChatLink).not.toBeVisible(); - } - - await page.goto("/todo"); + // Create unique test chat + const chatId = createRandomChat(page); + + await page.goto(`/todo/${chatId}`); // Add a test todo const todoInput = page.locator('input[placeholder="Add a todo"]'); diff --git a/e2e/next-ai-kitchen-sink/turbo.json b/e2e/next-ai-kitchen-sink/turbo.json index d1edffbd5a5..51e3070e1ce 100644 --- a/e2e/next-ai-kitchen-sink/turbo.json +++ b/e2e/next-ai-kitchen-sink/turbo.json @@ -4,6 +4,10 @@ "test": { "dependsOn": ["build"], "cache": false + }, + "test:ui": { + "dependsOn": ["build"], + "cache": false } } -} \ No newline at end of file +} diff --git a/e2e/next-sandbox/global.ts b/e2e/next-sandbox/global.ts index 25022104161..362aefe4cfe 100644 --- a/e2e/next-sandbox/global.ts +++ b/e2e/next-sandbox/global.ts @@ -7,6 +7,7 @@ declare global { }; Storage: { + initialRoom?: string; items?: LiveList; }; } diff --git a/e2e/next-sandbox/package.json b/e2e/next-sandbox/package.json index 7cb60d4f8f1..cb218fecdef 100644 --- a/e2e/next-sandbox/package.json +++ b/e2e/next-sandbox/package.json @@ -37,6 +37,7 @@ "@types/jest": "^29.5.14", "@types/lodash": "^4.17.13", "eslint-config-next": "^14.2.20", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "playwright": "^1.54.2" } } diff --git a/e2e/next-sandbox/pages/multi/index.tsx b/e2e/next-sandbox/pages/multi/index.tsx index 065af3eb055..fb2e838d687 100644 --- a/e2e/next-sandbox/pages/multi/index.tsx +++ b/e2e/next-sandbox/pages/multi/index.tsx @@ -13,6 +13,7 @@ type Presence = { }; type Storage = { + initialRoom: string; items?: LiveList; }; @@ -28,7 +29,10 @@ const { } = createRoomContext(client); const initialPresence = (): Presence => ({}); -const initialStorage = (): Storage => ({ items: new LiveList([]) }); +const initialStorageForRoom = (roomId: string) => (): Storage => ({ + initialRoom: roomId, + items: new LiveList([]), +}); export default function Home() { const [numColumns, setNumColumns] = useState(1); @@ -128,7 +132,7 @@ function RoomProviderBlock({ index }: RoomProviderBlockProps) { @@ -170,12 +174,16 @@ function Sandbox({ index }: SandboxProps) { const renderCount = useRenderCount(); const socketStatus = useStatus(); const others = useOthers(); + const initialRoom = useStorage((root) => root.initialRoom); const items = useStorage((root) => root.items); const push = useMutation( ({ storage }, value: string) => storage.get("items")?.push(value), [] ); - const clear = useMutation(({ storage }) => storage.get("items")?.clear(), []); + const clear = useMutation(({ storage }) => { + storage.delete("initialRoom"); + storage.get("items")?.clear(); + }, []); const [myPresence, updateMyPresence] = useMyPresence(); const me = useSelf(); const theirPresence = others[0]?.presence; @@ -264,6 +272,11 @@ function Sandbox({ index }: SandboxProps) {

Storage

+
diff --git a/e2e/next-sandbox/pages/multi/with-global-augmentation.tsx b/e2e/next-sandbox/pages/multi/with-global-augmentation.tsx index 870a5417046..89f35c01465 100644 --- a/e2e/next-sandbox/pages/multi/with-global-augmentation.tsx +++ b/e2e/next-sandbox/pages/multi/with-global-augmentation.tsx @@ -25,9 +25,11 @@ import Button from "../../utils/Button"; import { createLiveblocksClientOptions } from "../../utils/createClient"; const initialPresence = (): Liveblocks["Presence"] => ({}); -const initialStorage = (): Liveblocks["Storage"] => ({ - items: new LiveList([]), -}); +const initialStorageForRoom = + (roomId: string) => (): Liveblocks["Storage"] => ({ + initialRoom: roomId, + items: new LiveList([]), + }); export default function Home() { const count = useRenderCount(); @@ -164,7 +166,7 @@ function RoomProviderBlock({ index }: RoomProviderBlockProps) { @@ -206,12 +208,16 @@ function Sandbox({ index }: SandboxProps) { const renderCount = useRenderCount(); const socketStatus = useStatus(); const others = useOthers(); + const initialRoom = useStorage((root) => root.initialRoom); const items = useStorage((root) => root.items); const push = useMutation( ({ storage }, value: string) => storage.get("items")?.push(value), [] ); - const clear = useMutation(({ storage }) => storage.get("items")?.clear(), []); + const clear = useMutation(({ storage }) => { + storage.delete("initialRoom"); + storage.get("items")?.clear(); + }, []); const [myPresence, updateMyPresence] = useMyPresence(); const me = useSelf(); const theirPresence = others[0]?.presence; @@ -305,6 +311,11 @@ function Sandbox({ index }: SandboxProps) {

Storage

+
diff --git a/e2e/next-sandbox/playwright.config.ts b/e2e/next-sandbox/playwright.config.ts index be2016b7b91..baf53da513c 100644 --- a/e2e/next-sandbox/playwright.config.ts +++ b/e2e/next-sandbox/playwright.config.ts @@ -1,5 +1,4 @@ -import type { PlaywrightTestConfig } from "@playwright/test"; -import { devices } from "@playwright/test"; +import { defineConfig, devices } from "@playwright/test"; /** * Read environment variables from file. @@ -10,7 +9,7 @@ import { devices } from "@playwright/test"; /** * See https://playwright.dev/docs/test-configuration. */ -const config: PlaywrightTestConfig = { +export default defineConfig({ testDir: "./test", /* Maximum time one test can run for. */ timeout: 60 * 1000, @@ -24,30 +23,40 @@ const config: PlaywrightTestConfig = { /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ - retries: process.env.CI ? 2 : 1, + retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 4 : 6, + /* Fully parallel test execution */ + fullyParallel: true, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: "html", + reporter: [ + ["html", { title: "Liveblocks Next Sandbox E2E Tests" }], + ["github"], + ], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { headless: process.env.CI || process.env.HEADLESS ? true : false, viewport: { width: 640, height: 800 }, permissions: ["clipboard-write", "clipboard-read"], - /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ - actionTimeout: 0, + /* Maximum time each action such as `click()` can take. 10s local, 15s CI. */ + actionTimeout: process.env.CI ? 15000 : 10000, /* Base URL to use in actions like `await page.goto('/')`. */ // baseURL: 'http://localhost:3000', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: "retain-on-failure", + trace: "on-first-retry", + video: "on-first-retry", + screenshot: "on-first-failure", // Only captures main page }, /* Configure projects for major browsers */ projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] }, + use: { + ...devices["Desktop Chrome"], + channel: "chromium", // Enable the use of New Headless mode in Chromium + }, }, // { // name: "firefox", @@ -64,7 +73,7 @@ const config: PlaywrightTestConfig = { ? "npm run start" // Test production builds on CI : "npm run dev", // Test dev builds on CI (with React StrictMode enabled) port: 3007, + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, }, -}; - -export default config; +}); diff --git a/e2e/next-sandbox/test/multi.test.ts b/e2e/next-sandbox/test/multi.test.ts index 8554b5fde92..c00244fc290 100644 --- a/e2e/next-sandbox/test/multi.test.ts +++ b/e2e/next-sandbox/test/multi.test.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { test } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { expectJson, @@ -10,23 +10,23 @@ import { waitForJson, } from "./utils"; -// NOTE: The tests below don't play well with concurrency just yet. The reason -// is that they unmount, remount, and then check that the connection ID -// increased by exactly one. This is not super robust. What matters is that the -// connection ID increased, but if it increased by more than 1, that could also -// be just fine. Better to express the conditional like that. For now, just not -// run them concurrently to avoid it. -// test.describe.configure({ mode: "parallel" }); - test.describe("Multiple rooms (index)", () => { const TEST_URL = "http://localhost:3007/multi/"; let pages: Page[]; + let roomPrefix: string; + let roomSuffixCounter: number; + + const getUniqueRoomSuffix = () => `-${roomSuffixCounter++}`; test.beforeEach(async ({}, testInfo) => { const room = genRoomId(testInfo); + roomPrefix = `${room}-`; + // Start counter from worker ID to ensure unique room suffixes across workers + const workerId = testInfo.parallelIndex || 0; + roomSuffixCounter = workerId * 100; // Give each worker a unique range pages = await preparePages( - `${TEST_URL}?room=${encodeURIComponent(room)}`, + `${TEST_URL}?room=${encodeURIComponent(room)}&user=${workerId}`, { n: 1, width: 1024 } // Open only a single, but wider, browser window ); }); @@ -38,8 +38,9 @@ test.describe("Multiple rooms (index)", () => { test("mount, unmount, connection ID must be incremented", async () => { const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; - await page.fill("#input_1", "e2e:multi-A"); + await page.fill("#input_1", roomA); await page.click("#mount_1"); await waitForJson(page, "#socketStatus_1", "connected"); @@ -54,8 +55,10 @@ test.describe("Multiple rooms (index)", () => { test("mount, change room, change room back", async () => { const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; + const roomB = `${roomPrefix}-${getUniqueRoomSuffix()}`; - await page.fill("#input_1", "e2e:multi-A"); + await page.fill("#input_1", roomA); await page.click("#mount_1"); // Initial mount @@ -64,12 +67,12 @@ test.describe("Multiple rooms (index)", () => { const initialConnId = (await getJson(page, "#connectionId_1")) as number; // Change while mounted - await page.fill("#input_1", "e2e:multi-B"); + await page.fill("#input_1", roomB); // await waitForJson(page, "#socketStatus_1", "connecting"); await waitForJson(page, "#socketStatus_1", "connected"); // Change back - await page.fill("#input_1", "e2e:multi-A"); + await page.fill("#input_1", roomA); // await waitForJson(page, "#socketStatus_1", "connecting"); await waitForJson(page, "#socketStatus_1", "connected"); @@ -78,11 +81,13 @@ test.describe("Multiple rooms (index)", () => { test("mount same room twice as siblings, change room, change room back, should retain connection ID", async () => { const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; + const roomB = `${roomPrefix}-${getUniqueRoomSuffix()}`; // Set up await page.click("#add-column"); - await page.fill("#input_1", "e2e:multi-A"); - await page.fill("#input_2", "e2e:multi-A"); + await page.fill("#input_1", roomA); + await page.fill("#input_2", roomA); await page.click("#mount_1"); await page.click("#mount_2"); await waitForJson(page, "#socketStatus_1", "connected"); @@ -93,12 +98,12 @@ test.describe("Multiple rooms (index)", () => { await expectJson(page, "#connectionId_2", initialConnId); // Change while mounted - await page.fill("#input_2", "e2e:multi-B"); + await page.fill("#input_2", roomB); // await waitForJson(page, "#socketStatus_2", "connecting"); await waitForJson(page, "#socketStatus_2", "connected"); // Change back - await page.fill("#input_2", "e2e:multi-A"); + await page.fill("#input_2", roomA); await waitForJson(page, "#socketStatus_2", "connected"); await expectJson(page, "#connectionId_2", initialConnId); @@ -106,24 +111,26 @@ test.describe("Multiple rooms (index)", () => { test("mount same room twice, change room twice, should have same connection ID", async () => { const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; + const roomB = `${roomPrefix}-${getUniqueRoomSuffix()}`; // Set up await page.click("#add-column"); - await page.fill("#input_1", "e2e:multi-A"); - await page.fill("#input_2", "e2e:multi-A"); + await page.fill("#input_1", roomA); + await page.fill("#input_2", roomA); await page.click("#mount_1"); await page.click("#mount_2"); await waitForJson(page, "#socketStatus_1", "connected"); await waitForJson(page, "#socketStatus_2", "connected"); // Change while mounted - await page.fill("#input_2", "e2e:multi-B"); + await page.fill("#input_2", roomB); // await waitForJson(page, "#socketStatus_2", "connecting"); await waitForJson(page, "#socketStatus_2", "connected"); const connId = (await getJson(page, "#connectionId_2")) as number; // Change the other room instance as well - await page.fill("#input_1", "e2e:multi-B"); + await page.fill("#input_1", roomB); // They should share the same connection ID now await waitForJson(page, "#socketStatus_1", "connected"); @@ -132,6 +139,7 @@ test.describe("Multiple rooms (index)", () => { test("mount same room 5 times, connection ID only changes after last instance is no longer mounted", async () => { const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; await page.click("#mount_1"); @@ -140,11 +148,11 @@ test.describe("Multiple rooms (index)", () => { await page.click("#add-column"); await page.click("#add-column"); - await page.fill("#input_1", "e2e:multi-A"); - await page.fill("#input_2", "e2e:multi-A"); - await page.fill("#input_3", "e2e:multi-A"); - await page.fill("#input_4", "e2e:multi-A"); - await page.fill("#input_5", "e2e:multi-A"); + await page.fill("#input_1", roomA); + await page.fill("#input_2", roomA); + await page.fill("#input_3", roomA); + await page.fill("#input_4", roomA); + await page.fill("#input_5", roomA); // Set up await page.click("#mount_2"); @@ -199,43 +207,111 @@ test.describe("Multiple rooms (index)", () => { test("nested room providers", async () => { const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; + const roomB = `${roomPrefix}-${getUniqueRoomSuffix()}`; // Set up three levels of nesting - await page.fill("#input_1", "e2e:multi-A"); + await page.fill("#input_1", roomA); await page.click("#mount_1"); await page.click("#nest_1"); - await page.fill("#input_1_1", "e2e:multi-A"); + await page.fill("#input_1_1", roomA); await page.click("#mount_1_1"); await page.click("#nest_1_1"); - await page.fill("#input_1_1_1", "e2e:multi-A"); + await page.fill("#input_1_1_1", roomA); await page.click("#mount_1_1_1"); await waitForJson(page, "#socketStatus_1_1_1", "connected"); const connId = (await getJson(page, "#connectionId_1_1_1")) as number; - await page.fill("#input_1", "e2e:multi-B"); - await page.fill("#input_1_1", "e2e:multi-B"); + await page.fill("#input_1", roomB); + await page.fill("#input_1_1", roomB); await page.click("#inc_1_1_1"); // Trigger a re-render explicitly await expectJson(page, "#socketStatus_1_1_1", "connected"); // Check for regression -- socket should stay connected! await expectJson(page, "#connectionId_1_1_1", connId); // No change here - await page.fill("#input_1", "e2e:multi-A"); + await page.fill("#input_1", roomA); await page.click("#unmount_1"); await page.click("#mount_1"); await waitForJson(page, "#connectionId_1", connId + 1); }); + + test("initialStorage value DOES get reevaluated if the room ID changes", async () => { + const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; + const roomB = `${roomPrefix}-${getUniqueRoomSuffix()}`; + + // ----------------------- + // Test setup + // ----------------------- + await page.click("#add-column"); + + // Mount roomA first + await page.fill("#input_1", roomA); + await page.click("#mount_1"); + + // Mount roomB second + await page.fill("#input_2", roomB); + await page.click("#mount_2"); + + // Wait for connections + await waitForJson(page, "#socketStatus_1", "connected"); + await waitForJson(page, "#socketStatus_2", "connected"); + + // Hit "Clear" on both + await page.click("#clear_1"); + await page.click("#clear_2"); + + // Unmount both + await page.click("#unmount_1"); + await page.click("#unmount_2"); + + // "Remove column" + await page.click("#remove-column"); + + // ----------------------- + // Start of actual test + // ----------------------- + + // Set room ID to roomA + await page.fill("#input_1", roomA); + + // Mount + await page.click("#mount_1"); + await waitForJson(page, "#socketStatus_1", "connected"); + + // Ensure that, when initialRoom_1 equals the roomA value + await expectJson(page, "#initialRoom_1", roomA); + + // Change room ID to roomB (without unmounting) + await page.fill("#input_1", roomB); + await waitForJson(page, "#socketStatus_1", "connected"); + + // Ensure that, when initialRoom_1 equals the roomB value + await expectJson(page, "#initialRoom_1", roomB); + + // Unmount + await page.click("#unmount_1"); + }); }); test.describe("Multiple rooms (global augmentation)", () => { const TEST_URL = "http://localhost:3007/multi/with-global-augmentation"; let pages: Page[]; + let roomPrefix: string; + let roomSuffixCounter: number; + + const getUniqueRoomSuffix = () => roomSuffixCounter++; test.beforeEach(async ({}, testInfo) => { const room = genRoomId(testInfo); + roomPrefix = `${room}-`; + // Start counter from worker ID to ensure unique room suffixes across workers + const workerId = testInfo.parallelIndex || 0; + roomSuffixCounter = workerId * 100; // Give each worker a unique range pages = await preparePages( - `${TEST_URL}?room=${encodeURIComponent(room)}`, + `${TEST_URL}?room=${encodeURIComponent(room)}&user=${workerId}`, { n: 1, width: 1024 } // Open only a single, but wider, browser window ); }); @@ -247,8 +323,9 @@ test.describe("Multiple rooms (global augmentation)", () => { test("mount, unmount, connection ID must be incremented", async () => { const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; - await page.fill("#input_1", "e2e:multi-A"); + await page.fill("#input_1", roomA); await page.click("#mount_1"); await waitForJson(page, "#socketStatus_1", "connected"); @@ -263,8 +340,10 @@ test.describe("Multiple rooms (global augmentation)", () => { test("mount, change room, change room back", async () => { const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; + const roomB = `${roomPrefix}-${getUniqueRoomSuffix()}`; - await page.fill("#input_1", "e2e:multi-A"); + await page.fill("#input_1", roomA); await page.click("#mount_1"); // Initial mount @@ -273,12 +352,12 @@ test.describe("Multiple rooms (global augmentation)", () => { const initialConnId = (await getJson(page, "#connectionId_1")) as number; // Change while mounted - await page.fill("#input_1", "e2e:multi-B"); + await page.fill("#input_1", roomB); // await waitForJson(page, "#socketStatus_1", "connecting"); await waitForJson(page, "#socketStatus_1", "connected"); // Change back - await page.fill("#input_1", "e2e:multi-A"); + await page.fill("#input_1", roomA); // await waitForJson(page, "#socketStatus_1", "connecting"); await waitForJson(page, "#socketStatus_1", "connected"); @@ -287,11 +366,13 @@ test.describe("Multiple rooms (global augmentation)", () => { test("mount same room twice as siblings, change room, change room back, should retain connection ID", async () => { const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; + const roomB = `${roomPrefix}-${getUniqueRoomSuffix()}`; // Set up await page.click("#add-column"); - await page.fill("#input_1", "e2e:multi-A"); - await page.fill("#input_2", "e2e:multi-A"); + await page.fill("#input_1", roomA); + await page.fill("#input_2", roomA); await page.click("#mount_1"); await page.click("#mount_2"); await waitForJson(page, "#socketStatus_1", "connected"); @@ -302,12 +383,12 @@ test.describe("Multiple rooms (global augmentation)", () => { await expectJson(page, "#connectionId_2", initialConnId); // Change while mounted - await page.fill("#input_2", "e2e:multi-B"); + await page.fill("#input_2", roomB); // await waitForJson(page, "#socketStatus_2", "connecting"); await waitForJson(page, "#socketStatus_2", "connected"); // Change back - await page.fill("#input_2", "e2e:multi-A"); + await page.fill("#input_2", roomA); await waitForJson(page, "#socketStatus_2", "connected"); await expectJson(page, "#connectionId_2", initialConnId); @@ -315,24 +396,26 @@ test.describe("Multiple rooms (global augmentation)", () => { test("mount same room twice, change room twice, should have same connection ID", async () => { const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; + const roomB = `${roomPrefix}-${getUniqueRoomSuffix()}`; // Set up await page.click("#add-column"); - await page.fill("#input_1", "e2e:multi-A"); - await page.fill("#input_2", "e2e:multi-A"); + await page.fill("#input_1", roomA); + await page.fill("#input_2", roomA); await page.click("#mount_1"); await page.click("#mount_2"); await waitForJson(page, "#socketStatus_1", "connected"); await waitForJson(page, "#socketStatus_2", "connected"); // Change while mounted - await page.fill("#input_2", "e2e:multi-B"); + await page.fill("#input_2", roomB); // await waitForJson(page, "#socketStatus_2", "connecting"); await waitForJson(page, "#socketStatus_2", "connected"); const connId = (await getJson(page, "#connectionId_2")) as number; // Change the other room instance as well - await page.fill("#input_1", "e2e:multi-B"); + await page.fill("#input_1", roomB); // They should share the same connection ID now await waitForJson(page, "#socketStatus_1", "connected"); @@ -341,6 +424,7 @@ test.describe("Multiple rooms (global augmentation)", () => { test("mount same room 5 times, connection ID only changes after last instance is no longer mounted", async () => { const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; await page.click("#mount_1"); @@ -349,11 +433,11 @@ test.describe("Multiple rooms (global augmentation)", () => { await page.click("#add-column"); await page.click("#add-column"); - await page.fill("#input_1", "e2e:multi-A"); - await page.fill("#input_2", "e2e:multi-A"); - await page.fill("#input_3", "e2e:multi-A"); - await page.fill("#input_4", "e2e:multi-A"); - await page.fill("#input_5", "e2e:multi-A"); + await page.fill("#input_1", roomA); + await page.fill("#input_2", roomA); + await page.fill("#input_3", roomA); + await page.fill("#input_4", roomA); + await page.fill("#input_5", roomA); // Set up await page.click("#mount_2"); @@ -408,27 +492,29 @@ test.describe("Multiple rooms (global augmentation)", () => { test("nested room providers", async () => { const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; + const roomB = `${roomPrefix}-${getUniqueRoomSuffix()}`; // Set up three levels of nesting - await page.fill("#input_1", "e2e:multi-A"); + await page.fill("#input_1", roomA); await page.click("#mount_1"); await page.click("#nest_1"); - await page.fill("#input_1_1", "e2e:multi-A"); + await page.fill("#input_1_1", roomA); await page.click("#mount_1_1"); await page.click("#nest_1_1"); - await page.fill("#input_1_1_1", "e2e:multi-A"); + await page.fill("#input_1_1_1", roomA); await page.click("#mount_1_1_1"); await waitForJson(page, "#socketStatus_1_1_1", "connected"); const connId = (await getJson(page, "#connectionId_1_1_1")) as number; - await page.fill("#input_1", "e2e:multi-B"); - await page.fill("#input_1_1", "e2e:multi-B"); + await page.fill("#input_1", roomB); + await page.fill("#input_1_1", roomB); await page.click("#inc_1_1_1"); // Trigger a re-render explicitly await expectJson(page, "#socketStatus_1_1_1", "connected"); // Check for regression -- socket should stay connected! await expectJson(page, "#connectionId_1_1_1", connId); // No change here - await page.fill("#input_1", "e2e:multi-A"); + await page.fill("#input_1", roomA); await page.click("#unmount_1"); await page.click("#mount_1"); @@ -438,27 +524,94 @@ test.describe("Multiple rooms (global augmentation)", () => { test("auth callback is always fresh", async () => { const page = pages[0]; + // Get the current render count and current echo value const initialRenderCount = await getJson( page, "#liveblocksProviderRenderCount" ); await page.click("#mount_1"); - await waitForJson(page, "#echo_1", initialRenderCount); + // Wait for connection to establish + await waitForJson(page, "#socketStatus_1", "connected"); - // Clicking logout has no effect, unless the top level + // Get the echo value after mounting (this reflects the render count when auth was called) + const initialEcho = await getJson(page, "#echo_1"); + + // Clicking logout has no effect on the echo value, since no re-auth happens await page.click("#logout"); - await waitForJson(page, "#echo_1", initialRenderCount); + await waitForJson(page, "#echo_1", initialEcho); // Things change when the top-level LiveblocksProvider is re-rendered. - // If that happens, a new authEndpoint function _instance_ should get - // invoked after pressing logout. + // This creates a new authEndpoint function instance. await page.click("#rerenderLiveblocksProvider"); const newRenderCount = await getJson( page, "#liveblocksProviderRenderCount" ); + + // Verify that the render count actually increased + expect(newRenderCount).toBeGreaterThan(initialRenderCount as number); + + // Now when we logout, it should trigger re-authentication with the NEW render count await page.click("#logout"); await waitForJson(page, "#echo_1", newRenderCount); }); + + test("initialStorage value DOES get reevaluated if the room ID changes", async () => { + const page = pages[0]; + const roomA = `${roomPrefix}-${getUniqueRoomSuffix()}`; + const roomB = `${roomPrefix}-${getUniqueRoomSuffix()}`; + + // ----------------------- + // Test setup + // ----------------------- + await page.click("#add-column"); + + // Mount roomA first + await page.fill("#input_1", roomA); + await page.click("#mount_1"); + + // Mount roomB second + await page.fill("#input_2", roomB); + await page.click("#mount_2"); + + // Wait for connections + await waitForJson(page, "#socketStatus_1", "connected"); + await waitForJson(page, "#socketStatus_2", "connected"); + + // Hit "Clear" on both + await page.click("#clear_1"); + await page.click("#clear_2"); + + // Unmount both + await page.click("#unmount_1"); + await page.click("#unmount_2"); + + // "Remove column" + await page.click("#remove-column"); + + // ----------------------- + // Start of actual test + // ----------------------- + + // Set room ID to roomA + await page.fill("#input_1", roomA); + + // Mount + await page.click("#mount_1"); + await waitForJson(page, "#socketStatus_1", "connected"); + + // Ensure that, when initialRoom_1 equals the roomA value + await expectJson(page, "#initialRoom_1", roomA); + + // Change room ID to roomB (without unmounting) + await page.fill("#input_1", roomB); + await waitForJson(page, "#socketStatus_1", "connected"); + + // Ensure that, when initialRoom_1 equals the roomB value + await expectJson(page, "#initialRoom_1", roomB); + + // Unmount + await page.click("#unmount_1"); + }); }); diff --git a/e2e/next-sandbox/test/storage/list-with-suspense.test.ts b/e2e/next-sandbox/test/storage/list-with-suspense.test.ts index 3578529fe0f..ae9bce1a0d0 100644 --- a/e2e/next-sandbox/test/storage/list-with-suspense.test.ts +++ b/e2e/next-sandbox/test/storage/list-with-suspense.test.ts @@ -104,7 +104,8 @@ test.describe("Storage w/ Suspense", () => { await waitUntilEqualOnAllPages(pages, "#items"); }); - test("fuzzy with undo/redo push delete and move", async () => { + // TODO Look into why this test is flaky + test.skip("fuzzy with undo/redo push delete and move", async () => { const [page1] = pages; await page1.click("#clear"); await waitForJson(pages, "#numItems", 0); diff --git a/e2e/next-sandbox/test/storage/list.test.ts b/e2e/next-sandbox/test/storage/list.test.ts index e63bf3ff793..ef6c47d3ed0 100644 --- a/e2e/next-sandbox/test/storage/list.test.ts +++ b/e2e/next-sandbox/test/storage/list.test.ts @@ -101,7 +101,8 @@ test.describe("Storage - LiveList", () => { await waitUntilEqualOnAllPages(pages, "#items"); }); - test("fuzzy with undo/redo push delete and move", async () => { + // TODO Look into why this test is flaky + test.skip("fuzzy with undo/redo push delete and move", async () => { const [page1] = pages; await page1.click("#clear"); await waitForJson(pages, "#numItems", 0); diff --git a/e2e/next-sandbox/test/utils.ts b/e2e/next-sandbox/test/utils.ts index 6f7e97edf4e..834b3eb6f17 100644 --- a/e2e/next-sandbox/test/utils.ts +++ b/e2e/next-sandbox/test/utils.ts @@ -38,14 +38,14 @@ export function genRoomId(testInfo: TestInfo) { .replace(/^-+/, "") .replace(/-+$/, ""); let roomId = `e2e:${title}`; - if (roomId.length > 128) { + if (roomId.length > 100) { // Room IDs cannot be longer than 128 chars. If this happens, take a short // hash from the full room ID, then cut it off and attach the hash. This // way, test names can still be arbitrarily long, human-readable (at least // the first part of it), and yet still stable for reuse, so we don't have // an ever-growing set of rooms when running against DEV or PROD. const hash = hash7(roomId); - roomId = roomId.slice(0, 128 - hash.length) + hash; + roomId = roomId.slice(0, 100 - hash.length) + hash; } return roomId; } diff --git a/package-lock.json b/package-lock.json index 98584af26ec..9ede00e7090 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "@tailwindcss/postcss": "^4", "eslint": "^9", "eslint-config-next": "15.3.1", + "playwright": "^1.54.2", "tailwindcss": "^4", "typescript": "^5" } @@ -1287,7 +1288,8 @@ "@types/jest": "^29.5.14", "@types/lodash": "^4.17.13", "eslint-config-next": "^14.2.20", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "playwright": "^1.54.2" } }, "e2e/next-sandbox/node_modules/@reduxjs/toolkit": { @@ -8568,6 +8570,38 @@ "node": ">=18" } }, + "node_modules/@playwright/test/node_modules/playwright": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/@playwright/test/node_modules/playwright-core": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "license": "MIT", @@ -29312,11 +29346,13 @@ } }, "node_modules/playwright": { - "version": "1.49.1", - "devOptional": true, + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.2.tgz", + "integrity": "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.49.1" + "playwright-core": "1.54.2" }, "bin": { "playwright": "cli.js" @@ -29329,8 +29365,10 @@ } }, "node_modules/playwright-core": { - "version": "1.49.1", - "devOptional": true, + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.2.tgz", + "integrity": "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==", + "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -37073,10 +37111,10 @@ }, "packages/liveblocks-client": { "name": "@liveblocks/client", - "version": "3.3.4", + "version": "3.4.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.3.4" + "@liveblocks/core": "3.4.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37085,7 +37123,7 @@ }, "packages/liveblocks-core": { "name": "@liveblocks/core", - "version": "3.3.4", + "version": "3.4.0", "license": "Apache-2.0", "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37103,11 +37141,11 @@ }, "packages/liveblocks-emails": { "name": "@liveblocks/emails", - "version": "3.3.4", + "version": "3.4.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.3.4", - "@liveblocks/node": "3.3.4" + "@liveblocks/core": "3.4.0", + "@liveblocks/node": "3.4.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37126,10 +37164,10 @@ }, "packages/liveblocks-node": { "name": "@liveblocks/node", - "version": "3.3.4", + "version": "3.4.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.3.4", + "@liveblocks/core": "3.4.0", "@stablelib/base64": "^1.0.1", "fast-sha256": "^1.3.0", "node-fetch": "^2.6.1" @@ -37144,11 +37182,11 @@ }, "packages/liveblocks-node-lexical": { "name": "@liveblocks/node-lexical", - "version": "3.3.4", + "version": "3.4.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.3.4", - "@liveblocks/node": "3.3.4", + "@liveblocks/core": "3.4.0", + "@liveblocks/node": "3.4.0", "yjs": "^13.6.18" }, "devDependencies": { @@ -37165,11 +37203,11 @@ }, "packages/liveblocks-node-prosemirror": { "name": "@liveblocks/node-prosemirror", - "version": "3.3.4", + "version": "3.4.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.3.4", - "@liveblocks/node": "3.3.4", + "@liveblocks/core": "3.4.0", + "@liveblocks/node": "3.4.0", "yjs": "^13.6.20" }, "devDependencies": { @@ -37189,11 +37227,11 @@ }, "packages/liveblocks-react": { "name": "@liveblocks/react", - "version": "3.3.4", + "version": "3.4.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4" + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37223,15 +37261,15 @@ }, "packages/liveblocks-react-blocknote": { "name": "@liveblocks/react-blocknote", - "version": "3.3.4", + "version": "3.4.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4", - "@liveblocks/react": "3.3.4", - "@liveblocks/react-tiptap": "3.3.4", - "@liveblocks/react-ui": "3.3.4", - "@liveblocks/yjs": "3.3.4", + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0", + "@liveblocks/react": "3.4.0", + "@liveblocks/react-tiptap": "3.4.0", + "@liveblocks/react-ui": "3.4.0", + "@liveblocks/yjs": "3.4.0", "@tiptap/core": "^2.7.2", "vitest-tsconfig-paths": "^3.4.1" }, @@ -37268,15 +37306,15 @@ }, "packages/liveblocks-react-lexical": { "name": "@liveblocks/react-lexical", - "version": "3.3.4", + "version": "3.4.0", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.1", - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4", - "@liveblocks/react": "3.3.4", - "@liveblocks/react-ui": "3.3.4", - "@liveblocks/yjs": "3.3.4", + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0", + "@liveblocks/react": "3.4.0", + "@liveblocks/react-ui": "3.4.0", + "@liveblocks/yjs": "3.4.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-toggle": "^1.1.0", "yjs": "^13.6.18" @@ -37805,15 +37843,15 @@ }, "packages/liveblocks-react-tiptap": { "name": "@liveblocks/react-tiptap", - "version": "3.3.4", + "version": "3.4.0", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.2", - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4", - "@liveblocks/react": "3.3.4", - "@liveblocks/react-ui": "3.3.4", - "@liveblocks/yjs": "3.3.4", + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0", + "@liveblocks/react": "3.4.0", + "@liveblocks/react-ui": "3.4.0", + "@liveblocks/yjs": "3.4.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-toggle": "^1.1.0", "@tiptap/core": "^2.7.2", @@ -38338,13 +38376,13 @@ }, "packages/liveblocks-react-ui": { "name": "@liveblocks/react-ui", - "version": "3.3.4", + "version": "3.4.0", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.2", - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4", - "@liveblocks/react": "3.3.4", + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0", + "@liveblocks/react": "3.4.0", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-slot": "^1.1.0", @@ -39672,11 +39710,11 @@ }, "packages/liveblocks-redux": { "name": "@liveblocks/redux", - "version": "3.3.4", + "version": "3.4.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4" + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -39833,10 +39871,10 @@ }, "packages/liveblocks-yjs": { "name": "@liveblocks/yjs", - "version": "3.3.4", + "version": "3.4.0", "dependencies": { - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4", + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0", "@noble/hashes": "^1.8.0", "js-base64": "^3.7.7", "y-indexeddb": "^9.0.12" @@ -39902,11 +39940,11 @@ }, "packages/liveblocks-zustand": { "name": "@liveblocks/zustand", - "version": "3.3.4", + "version": "3.4.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4" + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", diff --git a/packages/liveblocks-client/package.json b/packages/liveblocks-client/package.json index 7a8653e42bb..06b4807886c 100644 --- a/packages/liveblocks-client/package.json +++ b/packages/liveblocks-client/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/client", - "version": "3.3.4", + "version": "3.4.0", "description": "A client that lets you interact with Liveblocks servers. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -34,7 +34,7 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/core": "3.3.4" + "@liveblocks/core": "3.4.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", diff --git a/packages/liveblocks-core/e2e/list.test.ts b/packages/liveblocks-core/e2e/list.test.ts index c0da272ace5..55eb2eb49a4 100644 --- a/packages/liveblocks-core/e2e/list.test.ts +++ b/packages/liveblocks-core/e2e/list.test.ts @@ -5,7 +5,7 @@ import { lsonToJson } from "../src/immutable"; import type { Json } from "../src/lib/Json"; import { prepareSingleClientTest, prepareTestsConflicts } from "./utils"; -describe.skip("LiveList conflicts", () => { +describe("LiveList conflicts", () => { describe("insert conflicts", () => { test( "remote insert conflicts with another insert", @@ -51,9 +51,7 @@ describe.skip("LiveList conflicts", () => { ) ); - // TODO: This test is flaky and occasionally fails in CI--make it more robust - // See https://github.com/liveblocks/liveblocks/runs/7317877322?check_suite_focus=true#step:6:52 - test.skip( + test( "remote insert conflicts with move via undo", prepareTestsConflicts( { @@ -78,9 +76,7 @@ describe.skip("LiveList conflicts", () => { ) ); - // TODO: This test is flaky and occasionally fails in CI--make it more robust - // See https://github.com/liveblocks/liveblocks/runs/7317877322?check_suite_focus=true#step:6:67 - test.skip( + test( "remote insert conflicts with set", prepareTestsConflicts( { @@ -182,72 +178,78 @@ describe.skip("LiveList conflicts", () => { "remote set conflicts with an insert via undo", prepareTestsConflicts( { - list: new LiveList(["A"]), + list: new LiveList(["a"]), }, async ({ root1, root2, room2, wsUtils, assert }) => { - root1.get("list").set(0, "B"); + root1.get("list").set(0, "b"); root2.get("list").delete(0); room2.history.undo(); - assert({ list: ["B"] }, { list: ["A"] }); + assert({ list: ["b"] }, { list: ["a"] }); await wsUtils.flushSocket1Messages(); - assert({ list: ["B"] }, { list: ["B"] }); + assert({ list: ["b"] }, { list: ["b"] }); await wsUtils.flushSocket2Messages(); - assert({ list: ["B", "A"] }); + assert({ list: ["b", "a"] }); } ) ); - test( + // TODO These are known to still fail and need investigation + test.skip( "remote set conflicts with move", prepareTestsConflicts( { - list: new LiveList(["A", "B", "C"]), + list: new LiveList(["a", "b", "c"]), }, async ({ root1, root2, wsUtils, assert }) => { - root1.get("list").set(0, "D"); + // Client A replaces "a" with "X" + root1.get("list").set(0, "X"); + + // Client B simultaneously deletes "a", and moves "c" to the front root2.get("list").delete(0); root2.get("list").move(1, 0); - assert({ list: ["D", "B", "C"] }, { list: ["C", "B"] }); + assert({ list: ["X", "b", "c"] }, { list: ["c", "b"] }); await wsUtils.flushSocket1Messages(); - assert({ list: ["D", "B", "C"] }, { list: ["D", "B"] }); + assert({ list: ["X", "b", "c"] }, { list: ["X", "b"] }); await wsUtils.flushSocket2Messages(); - assert({ list: ["D", "B", "C"] }, { list: ["D", "B", "C"] }); + // Final state after conflict resolution + assert({ list: ["X", "b", "c"] }); } ) ); - test( + // TODO These are known to still fail and need investigation + test.skip( "remote set conflicts with move via undo", prepareTestsConflicts( { - list: new LiveList(["A", "B", "C"]), + list: new LiveList(["a", "b", "c"]), }, async ({ root1, root2, room2, wsUtils, assert }) => { - root1.get("list").set(0, "D"); + root1.get("list").set(0, "X"); root2.get("list").delete(0); root2.get("list").move(1, 0); root2.get("list").move(0, 1); room2.history.undo(); - assert({ list: ["D", "B", "C"] }, { list: ["C", "B"] }); + assert({ list: ["X", "b", "c"] }, { list: ["c", "b"] }); await wsUtils.flushSocket1Messages(); - assert({ list: ["D", "B", "C"] }, { list: ["D", "B"] }); + assert({ list: ["X", "b", "c"] }, { list: ["X", "b"] }); await wsUtils.flushSocket2Messages(); - assert({ list: ["D", "B", "C"] }, { list: ["D", "B", "C"] }); + assert({ list: ["X", "b", "c"] }); } ) ); @@ -256,21 +258,24 @@ describe.skip("LiveList conflicts", () => { "remote set conflicts with delete", prepareTestsConflicts( { - list: new LiveList(["A"]), + list: new LiveList(["a"]), }, async ({ root1, root2, wsUtils, assert }) => { - root1.get("list").set(0, "B"); + // Client A replaces "a" with "X" + root1.get("list").set(0, "X"); + + // Client B simultaneously deletes "a" root2.get("list").delete(0); - assert({ list: ["B"] }, { list: [] }); + assert({ list: ["X"] }, { list: [] }); await wsUtils.flushSocket1Messages(); - assert({ list: ["B"] }, { list: ["B"] }); + assert({ list: ["X"] }, { list: ["X"] }); await wsUtils.flushSocket2Messages(); - assert({ list: ["B"] }, { list: ["B"] }); + assert({ list: ["X"] }); } ) ); @@ -325,9 +330,7 @@ describe.skip("LiveList conflicts", () => { }); describe("move conflicts", () => { - // TODO: This test is flaky and occasionally fails in CI--make it more robust - // See https://github.com/liveblocks/liveblocks/runs/7278337421?check_suite_focus=true#step:6:52 - test.skip( + test( "remote move conflicts with move", prepareTestsConflicts( { @@ -499,9 +502,7 @@ describe.skip("LiveList conflicts", () => { ) ); - // TODO: This test is flaky and occasionally fails in CI--make it more robust - // See https://github.com/liveblocks/liveblocks/runs/7278546989?check_suite_focus=true#step:6:52 - test.skip( + test( "set / insert + set", prepareTestsConflicts( { @@ -549,9 +550,7 @@ describe.skip("LiveList conflicts", () => { ) ); - // TODO: This test is flaky and occasionally fails in CI--make it more robust - // See https://github.com/liveblocks/liveblocks/runs/7278076193?check_suite_focus=true#step:6:52 - test.skip( + test( "set + move / move + move", prepareTestsConflicts( { @@ -664,9 +663,7 @@ describe.skip("LiveList conflicts", () => { ) ); - // TODO: This test is flaky and occasionally fails in CI--make it more robust - // See https://github.com/liveblocks/liveblocks/runs/7278546989?check_suite_focus=true#step:6:79 - test.skip( + test( "insert + delete / insert", prepareTestsConflicts( { @@ -798,10 +795,8 @@ describe.skip("LiveList conflicts", () => { }); }); -describe("LiveList single client", () => { - // TODO: This test is flaky and occasionally fails in CI--make it more robust - // See https://github.com/liveblocks/liveblocks/actions/runs/6336685485/job/17210682821#step:5:52 - test.skip( +describe("LiveList single client tests", () => { + test( "fast consecutive sets on same index", prepareSingleClientTest( { diff --git a/packages/liveblocks-core/e2e/map.test.ts b/packages/liveblocks-core/e2e/map.test.ts index 81563fda62d..9e43ccd70ca 100644 --- a/packages/liveblocks-core/e2e/map.test.ts +++ b/packages/liveblocks-core/e2e/map.test.ts @@ -32,9 +32,7 @@ describe("LiveMap single client", () => { ) ); - // TODO: This test is flaky and occasionally fails in CI--make it more robust - // See https://github.com/liveblocks/liveblocks/runs/7278076193?check_suite_focus=true#step:6:85 - test.skip( + test( "remote set conflicts with a delete", prepareTestsConflicts( { diff --git a/packages/liveblocks-core/e2e/utils.ts b/packages/liveblocks-core/e2e/utils.ts index cc6fa6ffad8..53aeb6d4626 100644 --- a/packages/liveblocks-core/e2e/utils.ts +++ b/packages/liveblocks-core/e2e/utils.ts @@ -6,6 +6,7 @@ import { expect } from "vitest"; import WebSocket from "ws"; import type { BaseMetadata, NoInfr } from "../src"; +import { nanoid } from "../src"; import { createClient } from "../src/client"; import type { Status } from "../src/connection"; import type { LiveObject } from "../src/crdts/LiveObject"; @@ -72,6 +73,7 @@ async function initializeRoomForTest< baseUrl: process.env.NEXT_PUBLIC_LIVEBLOCKS_BASE_URL, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const { room, leave } = client.enterRoom(roomId, { initialPresence, initialStorage, @@ -82,7 +84,7 @@ async function initializeRoomForTest< room, leave, get ws() { - if (ws == null) { + if (ws === null) { throw new Error("Websocket should be initialized at this point"); } return ws; @@ -115,7 +117,7 @@ export function prepareTestsConflicts( }) => Promise ): () => Promise { return async () => { - const roomName = "storage-requirements-e2e-tests-" + new Date().getTime(); + const roomName = "storage-requirements-e2e-tests-" + nanoid(10); const actor1 = await initializeRoomForTest( roomName, @@ -136,19 +138,19 @@ export function prepareTestsConflicts( actor1.ws.resumeSend(); // Waiting until every messages are received by all clients. // We don't have a public way to know if everything has been received so we have to rely on time - await wait(1000); + await wait(600); }, flushSocket2Messages: async () => { actor2.ws.resumeSend(); // Waiting until every messages are received by all clients. // We don't have a public way to know if everything has been received so we have to rely on time - await wait(1000); + await wait(600); }, }; // Waiting until every messages are received by all clients. // We don't have a public way to know if everything has been received so we have to rely on time - await wait(1000); + await wait(600); actor1.ws.pauseSend(); actor2.ws.pauseSend(); @@ -171,15 +173,21 @@ export function prepareTestsConflicts( { isDeep: true } ); - function assert(immRoot1: ToImmutable, immRoot2?: ToImmutable) { - if (immRoot2 == null) { - immRoot2 = immRoot1; + function assert( + immRoot1: ToImmutable, + immRoot2: ToImmutable = immRoot1 + ) { + try { + expect(root1.toImmutable()).toEqual(immRoot1); + expect(immutableStorage1).toEqual(immRoot1); + expect(root2.toImmutable()).toEqual(immRoot2); + + expect(immutableStorage2).toEqual(immRoot2); + } catch (error) { + // Better stack trace (point to where assert is called instead) + Error.captureStackTrace(error as Error, assert); + throw error; } - - expect(root1.toImmutable()).toEqual(immRoot1); - expect(immutableStorage1).toEqual(immRoot1); - expect(root2.toImmutable()).toEqual(immRoot2); - expect(immutableStorage2).toEqual(immRoot2); } try { @@ -225,7 +233,7 @@ export function prepareSingleClientTest( // Waiting until every messages are received by all clients. // We don't have a public way to know if everything has been received so we have to rely on time - await wait(1000); + await wait(600); actor.ws.pauseSend(); @@ -241,7 +249,7 @@ export function prepareSingleClientTest( actor.ws.resumeSend(); // Waiting until every messages are received by all clients. // We don't have a public way to know if everything has been received so we have to rely on time - await wait(1000); + await wait(600); }, }); actor.leave(); diff --git a/packages/liveblocks-core/package.json b/packages/liveblocks-core/package.json index c9ac96302a4..47739733d53 100644 --- a/packages/liveblocks-core/package.json +++ b/packages/liveblocks-core/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/core", - "version": "3.3.4", + "version": "3.4.0", "description": "Private internals for Liveblocks. DO NOT import directly from this package!", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/liveblocks-core/src/__tests__/ai.test.ts b/packages/liveblocks-core/src/__tests__/ai.test.ts index 51734311857..485003d2858 100644 --- a/packages/liveblocks-core/src/__tests__/ai.test.ts +++ b/packages/liveblocks-core/src/__tests__/ai.test.ts @@ -1,6 +1,17 @@ import { describe, expect, test } from "vitest"; import { KnowledgeStack } from "../ai"; +import type { + AiAssistantContentPart, + AiAssistantDeltaUpdate, + AiExecutingToolInvocationPart, + AiReasoningDelta, + AiReceivingToolInvocationPart, + AiTextDelta, + AiToolInvocationDelta, + AiToolInvocationStreamStart, +} from "../types/ai"; +import { patchContentWithDelta } from "../types/ai"; describe("KnowledgeStack", () => { test("should be empty by default", () => { @@ -145,3 +156,803 @@ describe("KnowledgeStack", () => { expect(stack.get()).toEqual([]); }); }); + +describe("patchContentWithDelta", () => { + describe("text-delta", () => { + test("appends to existing text part", () => { + const content: AiAssistantContentPart[] = [ + { type: "text", text: "Hello " }, + ]; + const delta: AiTextDelta = { type: "text-delta", textDelta: "world!" }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([{ type: "text", text: "Hello world!" }]); + }); + + test("creates new text part when last part is not text", () => { + const content: AiAssistantContentPart[] = [ + { type: "reasoning", text: "Some reasoning" }, + ]; + const delta: AiTextDelta = { type: "text-delta", textDelta: "Hello" }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([ + { type: "reasoning", text: "Some reasoning" }, + { type: "text", text: "Hello" }, + ]); + }); + + test("creates new text part when content is empty", () => { + const content: AiAssistantContentPart[] = []; + const delta: AiTextDelta = { type: "text-delta", textDelta: "Hello" }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([{ type: "text", text: "Hello" }]); + }); + + test("creates new text part when last part is tool-invocation", () => { + const content: AiAssistantContentPart[] = [ + { + type: "tool-invocation", + stage: "executed", + invocationId: "inv-1", + name: "search", + args: { query: "test" }, + result: { type: "success", data: { results: [] } }, + }, + ]; + const delta: AiTextDelta = { type: "text-delta", textDelta: "Done!" }; + + patchContentWithDelta(content, delta); + + expect(content).toHaveLength(2); + expect(content[1]).toEqual({ type: "text", text: "Done!" }); + }); + }); + + describe("reasoning-delta", () => { + test("appends to existing reasoning part", () => { + const content: AiAssistantContentPart[] = [ + { type: "reasoning", text: "Let me think " }, + ]; + const delta: AiReasoningDelta = { + type: "reasoning-delta", + textDelta: "about this...", + }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([ + { type: "reasoning", text: "Let me think about this..." }, + ]); + }); + + test("creates new reasoning part when last part is not reasoning", () => { + const content: AiAssistantContentPart[] = [ + { type: "text", text: "Some text" }, + ]; + const delta: AiReasoningDelta = { + type: "reasoning-delta", + textDelta: "Thinking", + }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([ + { type: "text", text: "Some text" }, + { type: "reasoning", text: "Thinking" }, + ]); + }); + + test("creates new reasoning part when content is empty", () => { + const content: AiAssistantContentPart[] = []; + const delta: AiReasoningDelta = { + type: "reasoning-delta", + textDelta: "Starting to analyze...", + }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([ + { type: "reasoning", text: "Starting to analyze..." }, + ]); + }); + + test("handles empty textDelta in reasoning-delta", () => { + const content: AiAssistantContentPart[] = []; + const delta: AiReasoningDelta = { + type: "reasoning-delta", + textDelta: "", + }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([{ type: "reasoning", text: "" }]); + }); + }); + + describe("tool-stream", () => { + test("creates receiving tool invocation with empty partialArgs", () => { + const content: AiAssistantContentPart[] = []; + + patchContentWithDelta(content, { + type: "tool-stream", + invocationId: "inv-test", + name: "testTool", + }); + + expect(content).toEqual([ + expect.objectContaining({ + type: "tool-invocation", + stage: "receiving", + invocationId: "inv-test", + name: "testTool", + partialArgs: {}, + }), + ]); + }); + + test("appends tool-stream after existing content", () => { + const content: AiAssistantContentPart[] = [ + { type: "text", text: "Let me search for that" }, + ]; + const delta: AiToolInvocationStreamStart = { + type: "tool-stream", + invocationId: "inv-456", + name: "calculator", + }; + + patchContentWithDelta(content, delta); + + expect(content).toHaveLength(2); + expect(content[1]).toMatchObject({ + type: "tool-invocation", + stage: "receiving", + invocationId: "inv-456", + name: "calculator", + partialArgs: {}, + }); + }); + + test("allows multiple tool-streams with different invocationIds", () => { + const content: AiAssistantContentPart[] = []; + + patchContentWithDelta(content, { + type: "tool-stream", + invocationId: "inv-1", + name: "search", + }); + + patchContentWithDelta(content, { + type: "tool-stream", + invocationId: "inv-2", + name: "calculator", + }); + + expect(content).toHaveLength(2); + expect(content[0]).toMatchObject({ + invocationId: "inv-1", + name: "search", + }); + expect(content[1]).toMatchObject({ + invocationId: "inv-2", + name: "calculator", + }); + }); + + test("caches partialArgs computation between accesses", () => { + // Setup + // ===== + const content: AiAssistantContentPart[] = []; + + // Create two tool invocations (to test that each has its own cache) + patchContentWithDelta(content, { + type: "tool-stream", + invocationId: "inv-cache-test", + name: "testTool", + }); + + const tool1 = content[0] as AiReceivingToolInvocationPart; + + // Actual test + // =========== + + expect(tool1.partialArgs).toEqual({}); + expect(tool1.partialArgs).toBe(tool1.partialArgs); + // ^^^^ Referentially equal + + // Add some partial args text + patchContentWithDelta(content, { + type: "tool-delta", + delta: '{"query": "tes', + }); + + // Multiple accesses doesn't change the value... + expect(tool1.partialArgs).toEqual({ query: "tes" }); + expect(tool1.partialArgs).toEqual({ query: "tes" }); + + // ...in fact it returns the same value + const result1 = tool1.partialArgs; + expect(tool1.partialArgs).toBe(tool1.partialArgs); + // ^^^^ Referentially equal + + // Second tool invocation to test that each has its own cache + patchContentWithDelta(content, { + type: "tool-stream", + invocationId: "inv-cache-test-2", + name: "testTool2", + }); + patchContentWithDelta(content, { + type: "tool-delta", + delta: '{"different": "value"}', + }); + + const tool2 = content[1] as AiReceivingToolInvocationPart; + + // Each tool should have its own cached values + tool2.partialArgs; + + expect(tool2.partialArgs).toEqual({ different: "value" }); + expect(tool2.partialArgs).toBe(tool2.partialArgs); + + // First tool should still have its original cached value + expect(tool1.partialArgs).toBe(result1); // Still the same cached object + }); + }); + + describe("tool-delta", () => { + test("ignores delta when content is empty (no tool-stream yet)", () => { + const content: AiAssistantContentPart[] = []; + const delta: AiToolInvocationDelta = { + type: "tool-delta", + delta: '{"ignored": true}', + }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([]); + }); + + test("ignores delta when last part is text", () => { + const content: AiAssistantContentPart[] = [ + { type: "text", text: "Some text" }, + ]; + const delta: AiToolInvocationDelta = { + type: "tool-delta", + delta: '{"ignored": true}', + }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([{ type: "text", text: "Some text" }]); + }); + + test("ignores delta when last part is reasoning", () => { + const content: AiAssistantContentPart[] = [ + { type: "reasoning", text: "Thinking..." }, + ]; + const delta: AiToolInvocationDelta = { + type: "tool-delta", + delta: '{"ignored": true}', + }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([{ type: "reasoning", text: "Thinking..." }]); + }); + + test("ignores delta when last tool is executing", () => { + const content: AiAssistantContentPart[] = [ + { + type: "tool-invocation", + stage: "executing", + invocationId: "inv-123", + name: "search", + args: { query: "test" }, + }, + ]; + const delta: AiToolInvocationDelta = { + type: "tool-delta", + delta: '{"should": "be ignored"}', + }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([ + { + type: "tool-invocation", + stage: "executing", + invocationId: "inv-123", + name: "search", + args: { query: "test" }, + }, + ]); + }); + + test("ignores delta when last tool is executed", () => { + const content: AiAssistantContentPart[] = [ + { + type: "tool-invocation", + stage: "executed", + invocationId: "inv-123", + name: "search", + args: { query: "test" }, + result: { type: "success", data: { found: true } }, + }, + ]; + const delta: AiToolInvocationDelta = { + type: "tool-delta", + delta: '{"should": "be ignored"}', + }; + + patchContentWithDelta(content, delta); + + expect(content[0]).toMatchObject({ + stage: "executed", + invocationId: "inv-123", + }); + }); + + test("appends delta to receiving tool invocation", () => { + const content: AiAssistantContentPart[] = []; + + // Start the tool stream + patchContentWithDelta(content, { + type: "tool-stream", + invocationId: "inv-1", + name: "search", + }); + + // Add delta + patchContentWithDelta(content, { + type: "tool-delta", + delta: '{"query": "test"}', + }); + + expect(content).toEqual([ + expect.objectContaining({ + type: "tool-invocation", + stage: "receiving", + invocationId: "inv-1", + name: "search", + partialArgs: { query: "test" }, + }), + ]); + }); + + test("builds JSON progressively with multiple deltas", () => { + const content: AiAssistantContentPart[] = []; + + // Start the tool stream + patchContentWithDelta(content, { + type: "tool-stream", + invocationId: "inv-complex", + name: "api_call", + }); + + // Build up the JSON progressively + const deltas = [ + '{"', + 'method": "', + 'GET",', + ' "url": "https://', + "api.example.com", + '", "headers": {', + '"Accept": "application/json"', + "}}", + ]; + + for (const deltaText of deltas) { + patchContentWithDelta(content, { + type: "tool-delta", + delta: deltaText, + }); + } + + const tool = content[0] as AiReceivingToolInvocationPart; + expect(tool.partialArgs).toEqual({ + method: "GET", + url: "https://api.example.com", + headers: { Accept: "application/json" }, + }); + }); + + test("only affects the last receiving tool when multiple tools exist", () => { + const content: AiAssistantContentPart[] = []; + + // First tool (executed) + content.push({ + type: "tool-invocation", + stage: "executed", + invocationId: "inv-1", + name: "search", + args: { query: "first" }, + result: { type: "success", data: {} }, + }); + + // Second tool (receiving) + patchContentWithDelta(content, { + type: "tool-stream", + invocationId: "inv-2", + name: "calculator", + }); + + // Delta should only affect the second tool + patchContentWithDelta(content, { + type: "tool-delta", + delta: '{"expr": "2+2"}', + }); + + expect(content).toEqual([ + expect.objectContaining({ + type: "tool-invocation", + stage: "executed", + invocationId: "inv-1", + name: "search", + args: { query: "first" }, + }), + expect.objectContaining({ + type: "tool-invocation", + stage: "receiving", + invocationId: "inv-2", + name: "calculator", + partialArgs: { expr: "2+2" }, + }), + ]); + }); + }); + + describe("tool-invocation (executing/executed)", () => { + test("appends new executing tool when no matching invocationId exists", () => { + const content: AiAssistantContentPart[] = []; + const delta: AiExecutingToolInvocationPart = { + type: "tool-invocation", + stage: "executing", + invocationId: "inv-new", + name: "search", + args: { query: "test" }, + }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([delta]); + }); + + test("replaces receiving tool with executing tool (same invocationId)", () => { + const content: AiAssistantContentPart[] = [ + { + type: "tool-invocation", + stage: "receiving", + invocationId: "inv-123", + name: "search", + partialArgsText: '{"query": "par"}', + partialArgs: { dummy: "not used in this test" }, + }, + ]; + + const delta: AiExecutingToolInvocationPart = { + type: "tool-invocation", + stage: "executing", + invocationId: "inv-123", + name: "search", + args: { query: "partial" }, + }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([delta]); + }); + + test("replaces executing tool with executed tool (same invocationId)", () => { + const content: AiAssistantContentPart[] = [ + { + type: "tool-invocation", + stage: "executing", + invocationId: "inv-123", + name: "search", + args: { query: "test" }, + }, + ]; + + const delta: AiAssistantDeltaUpdate = { + type: "tool-invocation", + stage: "executing", + invocationId: "inv-123", + name: "search", + args: { query: "test" }, + }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([delta]); + }); + + test("replaces tool in middle of content array", () => { + const content: AiAssistantContentPart[] = [ + { type: "text", text: "Before" }, + { + type: "tool-invocation", + stage: "receiving", + invocationId: "inv-123", + name: "search", + partialArgsText: '{"q": "test"}', + partialArgs: { dummy: "not used in this test" }, + }, + { type: "text", text: "After" }, + ]; + + const delta: AiExecutingToolInvocationPart = { + type: "tool-invocation", + stage: "executing", + invocationId: "inv-123", + name: "search", + args: { query: "complete" }, + }; + + patchContentWithDelta(content, delta); + + expect(content).toEqual([ + { type: "text", text: "Before" }, + delta, + { type: "text", text: "After" }, + ]); + }); + + test("replaces the LAST matching tool when multiple have same invocationId", () => { + const content: AiAssistantContentPart[] = [ + { + type: "tool-invocation", + stage: "receiving", + invocationId: "inv-dup", + name: "first", + partialArgsText: "a", + partialArgs: { dummy: "not used in this test" }, + }, + { type: "text", text: "Middle" }, + { + type: "tool-invocation", + stage: "receiving", + invocationId: "inv-dup", + name: "second", + partialArgsText: "b", + partialArgs: { dummy: "not used in this test" }, + }, + ]; + + const delta: AiExecutingToolInvocationPart = { + type: "tool-invocation", + stage: "executing", + invocationId: "inv-dup", + name: "replaced", + args: { value: "test" }, + }; + + patchContentWithDelta(content, delta); + + // Should replace the LAST one (index 2) + expect(content[0]).toMatchObject({ name: "first" }); + expect(content[1]).toEqual({ type: "text", text: "Middle" }); + expect(content[2]).toEqual(delta); + }); + + test("does not affect tools with different invocationIds", () => { + const content: AiAssistantContentPart[] = [ + { + type: "tool-invocation", + stage: "receiving", + invocationId: "inv-1", + name: "tool1", + partialArgsText: "", + partialArgs: { dummy: "not used in this test" }, + }, + { + type: "tool-invocation", + stage: "executing", + invocationId: "inv-2", + name: "tool2", + args: { x: 1 }, + }, + ]; + + const delta: AiAssistantDeltaUpdate = { + type: "tool-invocation", + stage: "executing", + invocationId: "inv-3", + name: "tool3", + args: { y: 2 }, + }; + + patchContentWithDelta(content, delta); + + expect(content).toHaveLength(3); + expect(content[0]).toMatchObject({ invocationId: "inv-1" }); + expect(content[1]).toMatchObject({ invocationId: "inv-2" }); + expect(content[2]).toMatchObject({ invocationId: "inv-3" }); + }); + }); + + describe("integration scenarios", () => { + test("mutates the original content array in-place", () => { + const content: AiAssistantContentPart[] = []; + const originalContent = content; + + patchContentWithDelta(content, { + type: "text-delta", + textDelta: "Hello", + }); + + expect(content).toBe(originalContent); + expect(content).toEqual([{ type: "text", text: "Hello" }]); + }); + + test("handles mixed content flow: reasoning -> text -> tool", () => { + const content: AiAssistantContentPart[] = []; + + // Start with reasoning + patchContentWithDelta(content, { + type: "reasoning-delta", + textDelta: "Let me analyze", + }); + + // Continue reasoning + patchContentWithDelta(content, { + type: "reasoning-delta", + textDelta: " this problem", + }); + + // Switch to text + patchContentWithDelta(content, { + type: "text-delta", + textDelta: "I'll search for", + }); + + // Continue text + patchContentWithDelta(content, { + type: "text-delta", + textDelta: " information", + }); + + // Add tool + patchContentWithDelta(content, { + type: "tool-stream", + invocationId: "inv-1", + name: "search", + }); + + expect(content).toEqual([ + { type: "reasoning", text: "Let me analyze this problem" }, + { type: "text", text: "I'll search for information" }, + expect.objectContaining({ + type: "tool-invocation", + stage: "receiving", + invocationId: "inv-1", + name: "search", + }), + ]); + }); + + test("complete tool streaming workflow", () => { + const content: AiAssistantContentPart[] = []; + const deltas: AiAssistantDeltaUpdate[] = [ + { type: "tool-stream", invocationId: "inv-123", name: "search" }, + { type: "tool-delta", delta: '{"query": "' }, + { type: "tool-delta", delta: "hello" }, + { type: "tool-delta", delta: ', world"}' }, + ]; + + for (const delta of deltas) { + patchContentWithDelta(content, delta); + } + + expect(content).toEqual([ + expect.objectContaining({ + type: "tool-invocation", + stage: "receiving", + invocationId: "inv-123", + name: "search", + partialArgs: { query: "hello, world" }, + }), + ]); + }); + + test("multiple tools with interleaved text", () => { + const content: AiAssistantContentPart[] = []; + + // First tool + patchContentWithDelta(content, { + type: "tool-stream", + invocationId: "inv-1", + name: "search", + }); + patchContentWithDelta(content, { + type: "tool-delta", + delta: '{"q": "test"}', + }); + + // Text between tools + patchContentWithDelta(content, { + type: "text-delta", + textDelta: "Now calculating...", + }); + + // Second tool + patchContentWithDelta(content, { + type: "tool-stream", + invocationId: "inv-2", + name: "calculator", + }); + patchContentWithDelta(content, { + type: "tool-delta", + delta: '{"expr": "2+2"}', + }); + + expect(content).toHaveLength(3); + expect(content[0]).toMatchObject({ + type: "tool-invocation", + invocationId: "inv-1", + partialArgs: { q: "test" }, + }); + expect(content[1]).toEqual({ type: "text", text: "Now calculating..." }); + expect(content[2]).toMatchObject({ + type: "tool-invocation", + invocationId: "inv-2", + partialArgs: { expr: "2+2" }, + }); + }); + + test("tool lifecycle: receiving -> executing", () => { + const content: AiAssistantContentPart[] = []; + + // Start tool stream (receiving) + patchContentWithDelta(content, { + type: "tool-stream", + invocationId: "inv-lifecycle", + name: "api", + }); + + // Build arguments + patchContentWithDelta(content, { + type: "tool-delta", + delta: '{"endpoint": "/users", ', + }); + + expect(content).toEqual([ + expect.objectContaining({ + stage: "receiving", + partialArgs: { endpoint: "/users" }, + }), + ]); + + patchContentWithDelta(content, { + type: "tool-delta", + delta: '"method":"GET"}', + }); + + expect(content).toEqual([ + expect.objectContaining({ + stage: "receiving", + partialArgs: { endpoint: "/users", method: "GET" }, + }), + ]); + + // Transition to executing + patchContentWithDelta(content, { + type: "tool-invocation", + stage: "executing", + invocationId: "inv-lifecycle", + name: "api", + args: { endpoint: "/users", method: "GET" }, + }); + + expect(content).toEqual([ + expect.objectContaining({ + stage: "executing", + args: { endpoint: "/users", method: "GET" }, + }), + ]); + }); + }); +}); diff --git a/packages/liveblocks-core/src/ai.ts b/packages/liveblocks-core/src/ai.ts index a19a6fbe81f..28c4a2693a6 100644 --- a/packages/liveblocks-core/src/ai.ts +++ b/packages/liveblocks-core/src/ai.ts @@ -28,6 +28,7 @@ import type { import type { AbortAiResponse, AiAssistantDeltaUpdate, + AiAssistantMessage, AiChat, AiChatMessage, AiChatsQuery, @@ -56,7 +57,7 @@ import type { SetToolResultResponse, ToolResultResponse, } from "./types/ai"; -import { appendDelta } from "./types/ai"; +import { patchContentWithDelta } from "./types/ai"; import type { Awaitable } from "./types/Awaitable"; import type { InferFromSchema, @@ -120,6 +121,7 @@ export type AiToolInvocationProps< // Private APIs [kInternal]: { execute: AiToolExecuteCallback | undefined; + messageStatus: AiAssistantMessage["status"]; }; } >; @@ -579,7 +581,7 @@ function createStore_forChatMessages( const message = lut.get(messageId); if (message === undefined) return false; - appendDelta(message.contentSoFar, delta); + patchContentWithDelta(message.contentSoFar, delta); lut.set(messageId, message); return true; }); @@ -1361,7 +1363,7 @@ export function makeCreateSocketDelegateForAi( const url = new URL(baseUrl); url.protocol = url.protocol === "http:" ? "ws" : "wss"; - url.pathname = "/ai/v4"; + url.pathname = "/ai/v5"; // TODO: don't allow public key to do this if (authValue.type === "secret") { url.searchParams.set("tok", authValue.token.raw); diff --git a/packages/liveblocks-core/src/index.ts b/packages/liveblocks-core/src/index.ts index ea977050565..56a3359e2c5 100644 --- a/packages/liveblocks-core/src/index.ts +++ b/packages/liveblocks-core/src/index.ts @@ -157,7 +157,7 @@ export { batch, DerivedSignal, MutableSignal, Signal } from "./lib/signals"; export { SortedList } from "./lib/SortedList"; export { stableStringify } from "./lib/stringify"; export type { QueryParams, URLSafeString } from "./lib/url"; -export { generateUrl, sanitizeUrl, url, urljoin } from "./lib/url"; +export { generateUrl, isUrl, sanitizeUrl, url, urljoin } from "./lib/url"; export type { Brand, DistributiveOmit, @@ -168,6 +168,7 @@ export { b64decode, compactObject, entries, + findLastIndex, keys, mapValues, memoizeOnSuccess, diff --git a/packages/liveblocks-core/src/lib/__tests__/parsePartialJsonObject.test.ts b/packages/liveblocks-core/src/lib/__tests__/parsePartialJsonObject.test.ts new file mode 100644 index 00000000000..edfff1a0f99 --- /dev/null +++ b/packages/liveblocks-core/src/lib/__tests__/parsePartialJsonObject.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "vitest"; + +import { parsePartialJsonObject as p } from "../parsePartialJsonObject"; + +describe("parsePartialJsonObject", () => { + test("basic cases", () => { + expect(p("")).toEqual({}); + expect(p(" ")).toEqual({}); + expect(p("{")).toEqual({}); + expect(p('{"key"')).toEqual({}); + expect(p('{"key')).toEqual({}); + expect(p('{"key":')).toEqual({}); + expect(p('{"key":""')).toEqual({ key: "" }); + expect(p('{"key":"')).toEqual({ key: "" }); + expect(p('{"key":0')).toEqual({ key: 0 }); + expect(p('{"key":"hi')).toEqual({ key: "hi" }); + expect(p('{"key":"value"')).toEqual({ key: "value" }); + expect(p('{"key":"value"}')).toEqual({ key: "value" }); + }); + + test("arrays", () => { + expect(p('{"arr":[')).toEqual({ arr: [] }); + expect(p('{"arr":[1')).toEqual({ arr: [1] }); + expect(p('{"arr":[1,')).toEqual({ arr: [1] }); + expect(p('{"arr":["hi')).toEqual({ arr: ["hi"] }); + expect(p('{"arr":["hi"')).toEqual({ arr: ["hi"] }); + expect(p('{"arr":["hi"]')).toEqual({ arr: ["hi"] }); + expect(p('{"arr":[1,2,3')).toEqual({ arr: [1, 2, 3] }); + }); + + test("nested structures", () => { + expect(p('{"arr":[')).toEqual({ arr: [] }); + expect(p('{"arr":[{')).toEqual({ arr: [{}] }); + expect(p('{"arr":[{"nested"')).toEqual({ arr: [{}] }); + expect(p('{"arr":[{"nested":')).toEqual({ arr: [{}] }); + expect(p('{"arr":[{"nested":42')).toEqual({ arr: [{ nested: 42 }] }); + }); + + test("mixed nesting", () => { + expect(p('{"a":[1,{"b":')).toEqual({ a: [1, {}] }); + expect(p('{"a":[1,{"b":[')).toEqual({ a: [1, { b: [] }] }); + expect(p('{"a":{"b":["c"')).toEqual({ a: { b: ["c"] } }); + }); + + test("strings with special characters", () => { + expect(p('{"key":"val\\n')).toEqual({ key: "val\n" }); + expect(p('{"key":"val\\"')).toEqual({ key: 'val"' }); + expect(p('{"key":"val with spaces')).toEqual({ key: "val with spaces" }); + expect(p('{"unicode":"café')).toEqual({ unicode: "café" }); + }); + + test("numbers", () => { + expect(p('{"num":123')).toEqual({ num: 123 }); + expect(p('{"num":123.')).toEqual({ num: 123 }); + expect(p('{"num":-42')).toEqual({ num: -42 }); + expect(p('{"pi":3.')).toEqual({ pi: 3 }); + expect(p('{"pi":3.14')).toEqual({ pi: 3.14 }); + }); + + test("complex real-world examples", () => { + expect(p('{"users":[{"id":1,"name":"John')).toEqual({ + users: [{ id: 1, name: "John" }], + }); + expect(p('{"config":{"debug":true,"timeout":')).toEqual({ + config: { debug: true }, + }); + expect(p('{"data":[{"items":[{"type":"text","content":')).toEqual({ + data: [{ items: [{ type: "text" }] }], + }); + }); + + test("edge cases for coverage", () => { + // Test properly closed nested objects to trigger stack.pop for '}' + expect(p('{"a":{"b":{}}')).toEqual({ a: { b: {} } }); + expect(p('{"nested":{"deep":{"obj":{}}}}')).toEqual({ + nested: { deep: { obj: {} } }, + }); + + // Test escaped characters at end of incomplete strings + expect(p('{"key":"val\\"')).toEqual({ key: 'val"' }); + expect(p('{"key":"val\\\\"')).toEqual({ key: "val\\" }); + expect(p('{"esc":"test\\\\')).toEqual({ esc: "test\\" }); + + // Test combination of escapes and incomplete structure + expect(p('{"a":"b\\\\","c":')).toEqual({ a: "b\\" }); + + // Test escape handling in string backtracking + expect(p('{"a":"test\\\\value:')).toEqual({ a: "test\\value:" }); + expect(p('{"a":"test\\\\value":')).toEqual({}); + + // Test handling of input ending with colon after numeric value + expect(p('{"a":1:')).toEqual({ a: 1 }); + + // Cover escaped characters inside strings + expect(p('{"msg":"Say \\"hello\\"')).toEqual({ msg: 'Say "hello"' }); + expect(p('{"path":"C:\\\\Users')).toEqual({ path: "C:\\Users" }); + + // Cover array closing bracket stack operations + expect(p('{"arr":[1,2]}')).toEqual({ arr: [1, 2] }); + expect(p('{"nested":[[[]]]}')).toEqual({ nested: [[[]]] }); + expect(p('{"mixed":[{"a":1}]}')).toEqual({ mixed: [{ a: 1 }] }); + + // Cover error recovery logic for malformed JSON + + // Test colon removal for incomplete key-value pairs + expect(p('{"a":1,"incomplete":')).toEqual({ a: 1 }); + + // Test handling of unmatched quotes + expect(p('{"a":1,"bad":"incomplete')).toEqual({ a: 1, bad: "incomplete" }); + + // Test comma removal for trailing commas + expect(p('{"a":1,"b":2,')).toEqual({ a: 1, b: 2 }); + + // Test fallback to {} for completely malformed input + expect(p("not json at all")).toEqual({}); + expect(p('{"completely broken syntax",}')).toEqual({}); + expect(p('{"key":"completely broken syntax",}')).toEqual({}); + expect(p('{"key","completely broken syntax",}')).toEqual({}); + }); + + test("handles emojis and newlines", () => { + // Complete emojis in strings + expect(p('{"message":"Hello 👋 world')).toEqual({ + message: "Hello 👋 world", + }); + expect(p('{"reaction":"🎉","status":"complete')).toEqual({ + reaction: "🎉", + status: "complete", + }); + + // Partial/incomplete emojis (multi-byte sequences) + expect(p('{"partial":"test 👋')).toEqual({ partial: "test 👋" }); + expect(p('{"emoji":"🎉🎊')).toEqual({ emoji: "🎉🎊" }); + + // Newlines and whitespace in strings + expect(p('{"text":"line1\\nline2')).toEqual({ text: "line1\nline2" }); + expect(p('{"multiline":"first line\\nsecond')).toEqual({ + multiline: "first line\nsecond", + }); + + // Mixed emojis, newlines, and regular content + expect(p('{"log":"User clicked 👆\\nAction: success ✅')).toEqual({ + log: "User clicked 👆\nAction: success ✅", + }); + + // Emojis with arrays and objects + expect(p('{"reactions":["👍","👎","❤️')).toEqual({ + reactions: ["👍", "👎", "❤️"], + }); + expect(p('{"user":{"name":"Alice","status":"🟢 online')).toEqual({ + user: { name: "Alice", status: "🟢 online" }, + }); + }); +}); diff --git a/packages/liveblocks-core/src/lib/__tests__/url.test.ts b/packages/liveblocks-core/src/lib/__tests__/url.test.ts index 76e0253b5ac..5298aeb79d2 100644 --- a/packages/liveblocks-core/src/lib/__tests__/url.test.ts +++ b/packages/liveblocks-core/src/lib/__tests__/url.test.ts @@ -27,6 +27,11 @@ describe("sanitizeUrl", () => { ); }); + test("should support hash-only URLs", () => { + expect(sanitizeUrl("#")).toBe("#"); + expect(sanitizeUrl("#hash")).toBe("#hash"); + }); + test("should support ports, query params, and a hash", () => { expect(sanitizeUrl("https://localhost:3000/docs?query=value#hash")).toBe( "https://localhost:3000/docs?query=value#hash" @@ -71,7 +76,6 @@ describe("sanitizeUrl", () => { expect(sanitizeUrl("file:///etc/passwd")).toBe(null); expect(sanitizeUrl("//liveblocks.io")).toBe(null); expect(sanitizeUrl("")).toBe(null); - expect(sanitizeUrl("#")).toBe(null); }); }); diff --git a/packages/liveblocks-core/src/lib/parsePartialJsonObject.ts b/packages/liveblocks-core/src/lib/parsePartialJsonObject.ts new file mode 100644 index 00000000000..ee9d3eaa13c --- /dev/null +++ b/packages/liveblocks-core/src/lib/parsePartialJsonObject.ts @@ -0,0 +1,142 @@ +import type { JsonObject } from "./Json"; +import { tryParseJson } from "./utils"; + +export function parsePartialJsonObject(partial: string): JsonObject { + partial = partial.trimStart(); + + if (partial.charAt(0) !== "{") { + // Not an object, don't even try to parse it + return {}; + } + + // If it's already valid JSON, return as-is + if (partial.trimEnd().endsWith("}")) { + const quickCheck = tryParseJson(partial); + if (quickCheck) { + // Due to the '{' check above, we can safely assume it's an object + return quickCheck as JsonObject; + } + } + + let result = partial; + + // 1. Fix unclosed strings by appending a '"' if needed + let quoteCount = 0; + let escaped = false; + for (let i = 0; i < result.length; i++) { + const char = result[i]; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + quoteCount++; + } + } + + if (quoteCount % 2 === 1) { + result += '"'; + } + + // 2. Trim whitespace at the end + result = result.trimEnd(); + + // 3. If the last char is a ',', strip it + if (result.endsWith(",")) { + result = result.slice(0, -1); + } + + // 4. If the last char is a '.', strip it + if (result.endsWith(".")) { + result = result.slice(0, -1); + } + + // 5. Go from left to right and collect '[' and '{' in a stack + const stack: string[] = []; + let inString = false; + escaped = false; + + for (let i = 0; i < result.length; i++) { + const char = result[i]; + + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + } else { + if (char === '"') { + inString = true; + } else if (char === "{") { + stack.push("}"); + } else if (char === "[") { + stack.push("]"); + } else if ( + char === "}" && + stack.length > 0 && + stack[stack.length - 1] === "}" + ) { + stack.pop(); + } else if ( + char === "]" && + stack.length > 0 && + stack[stack.length - 1] === "]" + ) { + stack.pop(); + } + } + } + + // 6. Build the suffix string from the stack + const suffix = stack.reverse().join(""); + + // 7. Attempt to "just" add the missing ] and }'s. + { + const attempt = tryParseJson(result + suffix); + if (attempt) { + // If it parses, return the result + return attempt as JsonObject; + } + } + + // 8. If there is a parse failure, it's likely because we're missing a "value" for a key in an object. + + // a. if the last char is a ":" remove it + if (result.endsWith(":")) { + result = result.slice(0, -1); + } + + // b. if the last char now is a '"', remove that last string + if (result.endsWith('"')) { + let pos = result.length - 2; // Start before the closing quote + escaped = false; + + // Scan back to find the opening quote, accounting for escaping + while (pos >= 0) { + if (escaped) { + escaped = false; + } else if (result[pos] === "\\") { + escaped = true; + } else if (result[pos] === '"') { + // Found the opening quote + result = result.slice(0, pos); + break; + } + pos--; + } + } + + // c. if the last char now is a ',', strip it + if (result.endsWith(",")) { + result = result.slice(0, -1); + } + + // Re-add the missing brackets/braces + result += suffix; + + // 10. Run JSON.parse on the result again. it should now work! + return (tryParseJson(result) as JsonObject | undefined) ?? {}; // Still invalid JSON +} diff --git a/packages/liveblocks-core/src/lib/url.ts b/packages/liveblocks-core/src/lib/url.ts index 1000e560e43..6b0afa91698 100644 --- a/packages/liveblocks-core/src/lib/url.ts +++ b/packages/liveblocks-core/src/lib/url.ts @@ -83,6 +83,7 @@ export function url( * - Absolute URLs with an http or https protocol (e.g. https://liveblocks.io) * - Absolute URLs with a `www` prefix (e.g. www.liveblocks.io) * - Relative URLs (e.g. /path/to/page) + * - Hash-only URLs (e.g. #hash) * * The presence/absence of trailing slashes is preserved. * Rejected URLs are returned as `null`. @@ -93,6 +94,11 @@ export function sanitizeUrl(url: string): string | null { url = "https://" + url; } + // If the URL is an empty hash, return it as is. + if (url === "#") { + return url; + } + try { const isAbsolute = ABSOLUTE_URL_REGEX.test(url); const urlObject = new URL( @@ -160,3 +166,12 @@ export function generateUrl( ? urlObject.href : urlObject.href.replace(PLACEHOLDER_BASE_URL, ""); } + +export function isUrl(string: string): boolean { + try { + new URL(string); + return true; + } catch (_) { + return false; + } +} diff --git a/packages/liveblocks-core/src/lib/utils.ts b/packages/liveblocks-core/src/lib/utils.ts index e8558953fdf..da2a75a8419 100644 --- a/packages/liveblocks-core/src/lib/utils.ts +++ b/packages/liveblocks-core/src/lib/utils.ts @@ -246,3 +246,18 @@ export function memoizeOnSuccess( return cached; }; } + +/** + * Polyfill for Array.prototype.findLastIndex() + */ +export function findLastIndex( + arr: T[], + predicate: (value: T, index: number, obj: T[]) => boolean +): number { + for (let i = arr.length - 1; i >= 0; i--) { + if (predicate(arr[i], i, arr)) { + return i; + } + } + return -1; +} diff --git a/packages/liveblocks-core/src/types/ai.ts b/packages/liveblocks-core/src/types/ai.ts index a0c86246b2e..b716725168c 100644 --- a/packages/liveblocks-core/src/types/ai.ts +++ b/packages/liveblocks-core/src/types/ai.ts @@ -8,9 +8,11 @@ import type { JSONSchema7 } from "json-schema"; import { assertNever } from "../lib/assert"; import type { Json, JsonObject } from "../lib/Json"; +import { parsePartialJsonObject } from "../lib/parsePartialJsonObject"; import type { Relax } from "../lib/Relax"; import type { Resolve } from "../lib/Resolve"; import type { Brand } from "../lib/utils"; +import { findLastIndex } from "../lib/utils"; export type Cursor = Brand; export type ISODateString = Brand; @@ -332,7 +334,9 @@ export type AiReceivingToolInvocationPart = { stage: "receiving"; invocationId: string; name: string; - partialArgs: Json; + /** @internal */ + partialArgsText: string; // The raw, partial JSON text value + partialArgs: JsonObject; // The interpreted, partial JSON value }; export type AiExecutingToolInvocationPart = { @@ -373,6 +377,27 @@ export type AiReasoningDelta = Relax< | { type: "reasoning-delta"; signature: string } >; +// Available since protocol V5, this is the start of a tool invocation stream +export type AiToolInvocationStreamStart = { + type: "tool-stream"; + invocationId: string; + name: string; +}; + +// Available since protocol V5, this is a partial tool invocation that is being +// constructed by the server and sent to the client as a delta. The client will +// append this delta to the last tool invocation stream's partial JSON buffer +// that will eventually become the full `args` value when JSON.parse()'ed. +export type AiToolInvocationDelta = { + type: "tool-delta"; + /** + * The textual delta to be appended to the last tool invocation stream's + * partial JSON buffer that will eventually become the full `args` value when + * JSON.parse()'ed. + */ + delta: string; +}; + export type AiReasoningPart = { type: "reasoning"; text: string; @@ -395,9 +420,13 @@ export type AiAssistantContentPart = | AiToolInvocationPart; export type AiAssistantDeltaUpdate = - | AiTextDelta // ...or a delta to append to the last sent part - | AiReasoningDelta // ...or a delta to append to the last sent part - | AiExecutingToolInvocationPart; // ...or a tool invocation chunk + | AiTextDelta // a delta appended to the last part (if text) + | AiReasoningDelta // a delta appended to the last part (if reasoning) + | AiExecutingToolInvocationPart // a tool invocation ready to be executed by the client + + // Since protocol V5, if tool-call-streaming is enabled + | AiToolInvocationStreamStart // the start of a new tool-call stream + | AiToolInvocationDelta; // a partial/under-construction tool invocation (since protocol V5) export type AiUserMessage = { id: MessageId; @@ -484,7 +513,7 @@ export type AiKnowledgeSource = { // -------------------------------------------------------------------------------------------------- -export function appendDelta( +export function patchContentWithDelta( content: AiAssistantContentPart[], delta: AiAssistantDeltaUpdate ): void { @@ -514,9 +543,61 @@ export function appendDelta( } break; - case "tool-invocation": - content.push(delta); + case "tool-stream": { + let _cacheKey = ""; + let _cachedArgs: JsonObject = {}; + + const toolInvocation = { + type: "tool-invocation" as const, + stage: "receiving" as const, + invocationId: delta.invocationId, + name: delta.name, + partialArgsText: "", + get partialArgs(): JsonObject { + if (this.partialArgsText !== _cacheKey) { + _cachedArgs = parsePartialJsonObject(this.partialArgsText); + _cacheKey = this.partialArgsText; + } + return _cachedArgs; + }, + }; + content.push(toolInvocation); + break; + } + + case "tool-delta": { + // Take the last part, expect it to be a tool invocation in receiving + // stage. If not, ignore this delta. If it is, append the delta to the + // partialArgsText + if ( + lastPart?.type === "tool-invocation" && + lastPart.stage === "receiving" + ) { + lastPart.partialArgsText += delta.delta; + } + // Otherwise ignore the delta - it's out of order or unexpected + break; + } + + case "tool-invocation": { + // Find and replace any existing tool invocation with the same invocationId + // Search from right to left (find the last matching one) + const existingIndex = findLastIndex( + content, + (part) => + part.type === "tool-invocation" && + part.invocationId === delta.invocationId + ); + + if (existingIndex > -1) { + // Replace the existing one + content[existingIndex] = delta; + } else { + // No existing one found, just append + content.push(delta); + } break; + } default: return assertNever(delta, "Unhandled case"); diff --git a/packages/liveblocks-core/test-d/InferFromSchema.test-d.ts b/packages/liveblocks-core/test-d/InferFromSchema.test-d.ts index 925c7b4e5a0..6cf71e52e6a 100644 --- a/packages/liveblocks-core/test-d/InferFromSchema.test-d.ts +++ b/packages/liveblocks-core/test-d/InferFromSchema.test-d.ts @@ -469,7 +469,9 @@ function infer(x: T): InferFromSchema { if (!myTool.render) { expectType(myTool.render); } else { - const internal = { [kInternal]: { execute: undefined } }; + const internal = { + [kInternal]: { execute: undefined, messageStatus: "generating" as const }, + }; // Possible JSX rendering invocation 1 myTool.render({ @@ -559,7 +561,7 @@ function infer(x: T): InferFromSchema { expectType<"receiving" | "executing" | "executed">(stage); if (stage === "receiving") { - expectType(partialArgs); + expectType(partialArgs); expectType(args); expectType(result); } else if (stage === "executing") { @@ -587,7 +589,7 @@ function infer(x: T): InferFromSchema { expectType<"receiving" | "executing" | "executed">(stage); if (stage === "receiving") { - expectType(partialArgs); + expectType(partialArgs); expectType(args); expectType(result); } else if (stage === "executing") { diff --git a/packages/liveblocks-emails/package.json b/packages/liveblocks-emails/package.json index c9717f5c90d..e416425343d 100644 --- a/packages/liveblocks-emails/package.json +++ b/packages/liveblocks-emails/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/emails", - "version": "3.3.4", + "version": "3.4.0", "description": "A set of functions and utilities to make sending emails based on Liveblocks notification events easy. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -35,8 +35,8 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/core": "3.3.4", - "@liveblocks/node": "3.3.4" + "@liveblocks/core": "3.4.0", + "@liveblocks/node": "3.4.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc" diff --git a/packages/liveblocks-node-lexical/package.json b/packages/liveblocks-node-lexical/package.json index 552e66f3c0e..d14d928bec6 100644 --- a/packages/liveblocks-node-lexical/package.json +++ b/packages/liveblocks-node-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node-lexical", - "version": "3.3.4", + "version": "3.4.0", "description": "A server-side utility that lets you modify lexical documents hosted in Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -34,8 +34,8 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/core": "3.3.4", - "@liveblocks/node": "3.3.4", + "@liveblocks/core": "3.4.0", + "@liveblocks/node": "3.4.0", "yjs": "^13.6.18" }, "peerDependencies": { diff --git a/packages/liveblocks-node-prosemirror/package.json b/packages/liveblocks-node-prosemirror/package.json index 7f8431a6c61..68f44f0fb8e 100644 --- a/packages/liveblocks-node-prosemirror/package.json +++ b/packages/liveblocks-node-prosemirror/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node-prosemirror", - "version": "3.3.4", + "version": "3.4.0", "description": "A server-side utility that lets you modify prosemirror and tiptap documents hosted in Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -34,8 +34,8 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/core": "3.3.4", - "@liveblocks/node": "3.3.4", + "@liveblocks/core": "3.4.0", + "@liveblocks/node": "3.4.0", "yjs": "^13.6.20" }, "peerDependencies": { diff --git a/packages/liveblocks-node/package.json b/packages/liveblocks-node/package.json index 9f1292a245f..60396ef9f9b 100644 --- a/packages/liveblocks-node/package.json +++ b/packages/liveblocks-node/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node", - "version": "3.3.4", + "version": "3.4.0", "description": "A server-side utility that lets you set up a Liveblocks authentication endpoint. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -34,7 +34,7 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/core": "3.3.4", + "@liveblocks/core": "3.4.0", "@stablelib/base64": "^1.0.1", "fast-sha256": "^1.3.0", "node-fetch": "^2.6.1" diff --git a/packages/liveblocks-react-blocknote/package.json b/packages/liveblocks-react-blocknote/package.json index 9063d4c914a..45f0b1ab8be 100644 --- a/packages/liveblocks-react-blocknote/package.json +++ b/packages/liveblocks-react-blocknote/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-blocknote", - "version": "3.3.4", + "version": "3.4.0", "description": "An integration of BlockNote + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -42,12 +42,12 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4", - "@liveblocks/react": "3.3.4", - "@liveblocks/react-tiptap": "3.3.4", - "@liveblocks/react-ui": "3.3.4", - "@liveblocks/yjs": "3.3.4", + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0", + "@liveblocks/react": "3.4.0", + "@liveblocks/react-tiptap": "3.4.0", + "@liveblocks/react-ui": "3.4.0", + "@liveblocks/yjs": "3.4.0", "@tiptap/core": "^2.7.2", "vitest-tsconfig-paths": "^3.4.1" }, diff --git a/packages/liveblocks-react-lexical/package.json b/packages/liveblocks-react-lexical/package.json index d0f5000572e..9d1f148d63b 100644 --- a/packages/liveblocks-react-lexical/package.json +++ b/packages/liveblocks-react-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-lexical", - "version": "3.3.4", + "version": "3.4.0", "description": "An integration of Lexical + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -43,11 +43,11 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.1", - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4", - "@liveblocks/react": "3.3.4", - "@liveblocks/react-ui": "3.3.4", - "@liveblocks/yjs": "3.3.4", + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0", + "@liveblocks/react": "3.4.0", + "@liveblocks/react-ui": "3.4.0", + "@liveblocks/yjs": "3.4.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-toggle": "^1.1.0", "yjs": "^13.6.18" diff --git a/packages/liveblocks-react-lexical/src/mentions/mention-component.tsx b/packages/liveblocks-react-lexical/src/mentions/mention-component.tsx index 48a8c76a4cf..88c46970e7e 100644 --- a/packages/liveblocks-react-lexical/src/mentions/mention-component.tsx +++ b/packages/liveblocks-react-lexical/src/mentions/mention-component.tsx @@ -29,7 +29,7 @@ export function Mention({ {children} diff --git a/packages/liveblocks-react-lexical/src/mentions/mention-node.tsx b/packages/liveblocks-react-lexical/src/mentions/mention-node.tsx index 5f2da9eac6d..466ff260ee8 100644 --- a/packages/liveblocks-react-lexical/src/mentions/mention-node.tsx +++ b/packages/liveblocks-react-lexical/src/mentions/mention-node.tsx @@ -101,7 +101,7 @@ export class MentionNode extends DecoratorNode { decorate(): JSX.Element { return ( - {MENTION_CHARACTER} + {MENTION_CHARACTER} ); diff --git a/packages/liveblocks-react-tiptap/package.json b/packages/liveblocks-react-tiptap/package.json index 5a7a93710e0..95ae6de8f4a 100644 --- a/packages/liveblocks-react-tiptap/package.json +++ b/packages/liveblocks-react-tiptap/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-tiptap", - "version": "3.3.4", + "version": "3.4.0", "description": "An integration of TipTap + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -43,11 +43,11 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.2", - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4", - "@liveblocks/react": "3.3.4", - "@liveblocks/react-ui": "3.3.4", - "@liveblocks/yjs": "3.3.4", + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0", + "@liveblocks/react": "3.4.0", + "@liveblocks/react-ui": "3.4.0", + "@liveblocks/yjs": "3.4.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-toggle": "^1.1.0", "@tiptap/core": "^2.7.2", diff --git a/packages/liveblocks-react-tiptap/src/mentions/Mention.tsx b/packages/liveblocks-react-tiptap/src/mentions/Mention.tsx index ac7c2cb3701..ddca73bcfdc 100644 --- a/packages/liveblocks-react-tiptap/src/mentions/Mention.tsx +++ b/packages/liveblocks-react-tiptap/src/mentions/Mention.tsx @@ -14,13 +14,13 @@ export const Mention = forwardRef< return ( - {MENTION_CHARACTER} + {MENTION_CHARACTER} ); diff --git a/packages/liveblocks-react-tiptap/src/styles/index.css b/packages/liveblocks-react-tiptap/src/styles/index.css index 7d6e8c13a3f..bb69b25ca74 100644 --- a/packages/liveblocks-react-tiptap/src/styles/index.css +++ b/packages/liveblocks-react-tiptap/src/styles/index.css @@ -565,7 +565,7 @@ padding-inline: var(--lb-tiptap-ai-toolbar-padding); color: var(--lb-foreground-tertiary); user-select: none; - animation: lb-animation-shimmer-small 5s linear infinite; + animation: lb-animation-shimmer-text 5s linear infinite; } .lb-tiptap-ai-toolbar-dropdown { diff --git a/packages/liveblocks-react-ui/package.json b/packages/liveblocks-react-ui/package.json index 91146008a06..8bc8e7dc082 100644 --- a/packages/liveblocks-react-ui/package.json +++ b/packages/liveblocks-react-ui/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-ui", - "version": "3.3.4", + "version": "3.4.0", "description": "A set of React pre-built components for the Liveblocks products. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -76,9 +76,9 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.2", - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4", - "@liveblocks/react": "3.3.4", + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0", + "@liveblocks/react": "3.4.0", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-slot": "^1.1.0", diff --git a/packages/liveblocks-react-ui/src/components/AiChat.tsx b/packages/liveblocks-react-ui/src/components/AiChat.tsx index ea683fe5d50..0546c682028 100644 --- a/packages/liveblocks-react-ui/src/components/AiChat.tsx +++ b/packages/liveblocks-react-ui/src/components/AiChat.tsx @@ -393,6 +393,7 @@ const AiChatMessages = forwardRef( key={message.id} message={message} overrides={overrides} + components={components} /> ); } else if (message.role === "assistant") { @@ -489,9 +490,7 @@ export const AiChat = forwardRef( {...props} className={cn( "lb-root lb-ai-chat", - layout === "compact" - ? "lb-ai-chat:layout-compact" - : "lb-ai-chat:layout-inset", + `lb-ai-chat:layout-${layout}`, className )} > diff --git a/packages/liveblocks-react-ui/src/components/AiTool.tsx b/packages/liveblocks-react-ui/src/components/AiTool.tsx index 63d053b3311..4b054fb7bdd 100644 --- a/packages/liveblocks-react-ui/src/components/AiTool.tsx +++ b/packages/liveblocks-react-ui/src/components/AiTool.tsx @@ -48,6 +48,11 @@ export interface AiToolProps */ children?: ReactNode; + /** + * The visual appearance of the tool. + */ + variant?: "block" | "minimal"; + /** * Whether the content is currently collapsed. * It is not a traditional controlled value, as in if you set it to `true` it would only stay expanded. @@ -328,6 +333,7 @@ export const AiTool = Object.assign( collapsible, collapsed, onCollapsedChange, + variant = "block", className, ...props }, @@ -337,8 +343,15 @@ export const AiTool = Object.assign( stage, result, name, - [kInternal]: { execute }, + [kInternal]: { execute, messageStatus }, } = useAiToolInvocationContext(); + // Only mark the tool as pending visually (e.g. show a spinner, add a shimmer animation, etc.) + // if it has an `execute` method and it isn't in the "executed" stage. + const isVisuallyPending = + execute !== undefined && + stage !== "executed" && + // If it's in the "receiving" stage, we also check that the outer message is still generating. + (stage === "receiving" ? messageStatus === "generating" : true); const [semiControlledCollapsed, onSemiControlledCollapsed] = useSemiControllableState(collapsed ?? false, onCollapsedChange); // TODO: This check won't work for cases like: @@ -369,7 +382,11 @@ export const AiTool = Object.assign( return ( - + {icon ? (
{icon}
) : null} @@ -388,20 +410,21 @@ export const AiTool = Object.assign( ) : null} -
- {stage === "executed" ? ( - result.type === "success" ? ( - - ) : result.type === "error" ? ( - - ) : result.type === "cancelled" ? ( - - ) : null - ) : execute !== undefined ? ( - // Only show a spinner if the tool has an `execute` method. - - ) : null} -
+ {variant !== "minimal" ? ( +
+ {stage === "executed" ? ( + result.type === "success" ? ( + + ) : result.type === "error" ? ( + + ) : result.type === "cancelled" ? ( + + ) : null + ) : isVisuallyPending ? ( + + ) : null} +
+ ) : null}
{hasContent ? ( diff --git a/packages/liveblocks-react-ui/src/components/Comment.tsx b/packages/liveblocks-react-ui/src/components/Comment.tsx index bee84645796..597a9af585e 100644 --- a/packages/liveblocks-react-ui/src/components/Comment.tsx +++ b/packages/liveblocks-react-ui/src/components/Comment.tsx @@ -222,11 +222,11 @@ export function CommentMention({ case "user": return ( - {MENTION_CHARACTER} + {MENTION_CHARACTER} ); diff --git a/packages/liveblocks-react-ui/src/components/Composer.tsx b/packages/liveblocks-react-ui/src/components/Composer.tsx index 4f38bbdc8c9..2f8c9a141f5 100644 --- a/packages/liveblocks-react-ui/src/components/Composer.tsx +++ b/packages/liveblocks-react-ui/src/components/Composer.tsx @@ -339,8 +339,8 @@ function ComposerMention({ mention }: ComposerEditorMentionProps) { switch (mention.kind) { case "user": return ( - - {MENTION_CHARACTER} + + {MENTION_CHARACTER} ); diff --git a/packages/liveblocks-react-ui/src/components/Thread.tsx b/packages/liveblocks-react-ui/src/components/Thread.tsx index f3788fe2cfc..c15738576c5 100644 --- a/packages/liveblocks-react-ui/src/components/Thread.tsx +++ b/packages/liveblocks-react-ui/src/components/Thread.tsx @@ -41,9 +41,10 @@ import type { GlobalOverrides, ThreadOverrides, } from "../overrides"; +import { findLastIndex } from "@liveblocks/core"; + import { useOverrides } from "../overrides"; import { cn } from "../utils/cn"; -import { findLastIndex } from "../utils/find-last-index"; import type { CommentProps } from "./Comment"; import { Comment } from "./Comment"; import type { ComposerProps } from "./Composer"; @@ -231,7 +232,7 @@ export const Thread = forwardRef( const lastCommentIndex = useMemo(() => { return showDeletedComments ? thread.comments.length - 1 - : findLastIndex(thread.comments, (comment) => comment.body); + : findLastIndex(thread.comments, (comment) => Boolean(comment.body)); }, [showDeletedComments, thread.comments]); const hiddenComments = useMemo(() => { const maxVisibleCommentsCount = diff --git a/packages/liveblocks-react-ui/src/components/internal/AiChatAssistantMessage.tsx b/packages/liveblocks-react-ui/src/components/internal/AiChatAssistantMessage.tsx index c9202d761fe..6b6d7af5420 100644 --- a/packages/liveblocks-react-ui/src/components/internal/AiChatAssistantMessage.tsx +++ b/packages/liveblocks-react-ui/src/components/internal/AiChatAssistantMessage.tsx @@ -238,7 +238,11 @@ function ReasoningPart({ part, isStreaming, components }: ReasoningPartProps) {
- +
); diff --git a/packages/liveblocks-react-ui/src/components/internal/AiChatUserMessage.tsx b/packages/liveblocks-react-ui/src/components/internal/AiChatUserMessage.tsx index 289e7b1efc2..e84da9f64fe 100644 --- a/packages/liveblocks-react-ui/src/components/internal/AiChatUserMessage.tsx +++ b/packages/liveblocks-react-ui/src/components/internal/AiChatUserMessage.tsx @@ -1,17 +1,24 @@ -import type { - AiTextPart, - AiUserMessage, - WithNavigation, -} from "@liveblocks/core"; +import type { AiUserMessage, WithNavigation } from "@liveblocks/core"; import type { ComponentProps } from "react"; import { forwardRef, memo } from "react"; -import { AiMessage } from "../../_private"; +import type { GlobalComponents } from "../../components"; import { type GlobalOverrides, useOverrides } from "../../overrides"; +import * as AiMessage from "../../primitives/AiMessage"; +import type { AiMessageContentTextPartProps } from "../../primitives/AiMessage/types"; +import type { MarkdownComponents } from "../../primitives/Markdown"; import { cn } from "../../utils/cn"; +import { Prose } from "./Prose"; type UiUserMessage = WithNavigation; +type AiChatUserMessageComponents = { + /** + * The components used to render Markdown content. + */ + markdown?: Partial; +}; + /* ------------------------------------------------------------------------------------------------- * AiChatUserMessage * -----------------------------------------------------------------------------------------------*/ @@ -20,23 +27,35 @@ export interface AiChatUserMessageProps extends ComponentProps<"div"> { * The message to display. */ message: UiUserMessage; + /** * Override the component's strings. */ overrides?: Partial; + + /** + * Override the component's components. + */ + components?: Partial; } -type PlainTextPartProps = { - part: AiTextPart; -}; +interface TextPartProps extends AiMessageContentTextPartProps { + components?: Partial; +} -function PlainTextPart({ part }: PlainTextPartProps) { - return

{part.text}

; +function TextPart({ part, components }: TextPartProps) { + return ( + + ); } export const AiChatUserMessage = memo( forwardRef( - ({ message, className, overrides, ...props }, forwardedRef) => { + ({ message, className, overrides, components, ...props }, forwardedRef) => { const $ = useOverrides(overrides); return (
) : ( -
- -
+ ( + + ), + }} + /> )}
); diff --git a/packages/liveblocks-react-ui/src/components/internal/CodeBlock.tsx b/packages/liveblocks-react-ui/src/components/internal/CodeBlock.tsx index 776a9f779e4..d10c6bc08eb 100644 --- a/packages/liveblocks-react-ui/src/components/internal/CodeBlock.tsx +++ b/packages/liveblocks-react-ui/src/components/internal/CodeBlock.tsx @@ -45,7 +45,7 @@ export function CodeBlock({ title, code, overrides }: CodeBlockProps) { return ( -
+
{title}
diff --git a/packages/liveblocks-react-ui/src/components/internal/Prose.tsx b/packages/liveblocks-react-ui/src/components/internal/Prose.tsx index a70d1362fc0..15e3ad25fd7 100644 --- a/packages/liveblocks-react-ui/src/components/internal/Prose.tsx +++ b/packages/liveblocks-react-ui/src/components/internal/Prose.tsx @@ -16,6 +16,7 @@ import { CodeBlock as DefaultCodeBlock } from "./CodeBlock"; interface ProseProps extends ComponentProps<"div"> { content: string; + partial?: boolean; components?: Partial< GlobalComponents & { markdown?: Partial } >; @@ -46,6 +47,7 @@ const defaultMarkdownComponents: Partial = { */ export function Prose({ content, + partial, components, className, ...props @@ -55,7 +57,8 @@ export function Prose({ diff --git a/packages/liveblocks-react-ui/src/primitives/AiMessage/tool-invocation.tsx b/packages/liveblocks-react-ui/src/primitives/AiMessage/tool-invocation.tsx index 85eca0a14be..581a551d9c9 100644 --- a/packages/liveblocks-react-ui/src/primitives/AiMessage/tool-invocation.tsx +++ b/packages/liveblocks-react-ui/src/primitives/AiMessage/tool-invocation.tsx @@ -79,7 +79,15 @@ export function AiMessageToolInvocation({ ] ); + const partialArgs = part?.partialArgs; const props = useMemo(() => { + // NOTE: Not really used, but necessary to trick useMemo into re-evaluating + // when it changes. Without this trick, tool call streaming won't + // re-render. The reason this is needed is that `part` gets mutated + // in-place by the delta handling, rather than part being replaced by a new + // object on every chunk. + partialArgs; + const { type: _, ...rest } = part; return { ...rest, @@ -87,9 +95,10 @@ export function AiMessageToolInvocation({ types: undefined as never, [kInternal]: { execute: tool?.execute, + messageStatus: message.status, }, }; - }, [part, respond, tool?.execute]); + }, [part, respond, tool?.execute, message.status, partialArgs]); if (tool?.render === undefined) return null; return ( diff --git a/packages/liveblocks-react-ui/src/primitives/Composer/slate/plugins/custom-links.ts b/packages/liveblocks-react-ui/src/primitives/Composer/slate/plugins/custom-links.ts index 6bb72506825..2d1832b5777 100644 --- a/packages/liveblocks-react-ui/src/primitives/Composer/slate/plugins/custom-links.ts +++ b/packages/liveblocks-react-ui/src/primitives/Composer/slate/plugins/custom-links.ts @@ -1,3 +1,4 @@ +import { isUrl } from "@liveblocks/core"; import type { Editor as SlateEditor } from "slate"; import { Element as SlateElement, @@ -11,15 +12,6 @@ import { isPlainText, isText } from "../../../slate/utils/is-text"; import { filterActiveMarks } from "../../../slate/utils/marks"; import { selectionContainsInlines } from "../../../slate/utils/selection-contains-inlines"; -function isUrl(string: string) { - try { - new URL(string); - return true; - } catch (_) { - return false; - } -} - export function withCustomLinks(editor: SlateEditor): SlateEditor { const { isInline, normalizeNode, insertData } = editor; diff --git a/packages/liveblocks-react-ui/src/primitives/Markdown.tsx b/packages/liveblocks-react-ui/src/primitives/Markdown.tsx index ec64ddaacc2..025664e9a72 100644 --- a/packages/liveblocks-react-ui/src/primitives/Markdown.tsx +++ b/packages/liveblocks-react-ui/src/primitives/Markdown.tsx @@ -1,6 +1,11 @@ -import { assertNever, type Relax, sanitizeUrl } from "@liveblocks/core"; +import { assertNever, isUrl, type Relax, sanitizeUrl } from "@liveblocks/core"; import { Slot } from "@radix-ui/react-slot"; -import { Lexer, type MarkedToken, type Token, type Tokens } from "marked"; +import { + Lexer, + type MarkedToken, + type Token as DefaultToken, + type Tokens, +} from "marked"; import { type ComponentType, forwardRef, @@ -11,6 +16,44 @@ import { import type { ComponentPropsWithSlot } from "../types"; +const LIST_ITEM_CHECKBOX_REGEX = /^\[\s?(x)?\]?$/i; +const PARTIAL_LINK_IMAGE_REGEX = + /(?!)?\[(?!\^)(?[^\]]*)(?:\](?:\((?[^)]*)?)?)?$/; +const PARTIAL_TABLE_HEADER_REGEX = + /^\s*\|(?:[^|\n]+(?:\|[^|\n]+)*?)?\|?\s*(?:\n\s*\|\s*[-:|\s]*\s*)?$/; +const PARTIAL_EMOJI_REGEX = + /(?:\u200D|\uFE0F|\u20E3|\p{Regional_Indicator}|\p{Emoji_Presentation}|\p{Emoji_Modifier_Base}|\p{Emoji_Modifier})+$/u; +const TRAILING_NON_WHITESPACE_REGEX = /^\S*/; +const WHITESPACE_REGEX = /\s/; +const NEWLINE_REGEX = /\r\n?/g; +const BUFFERED_CHARACTERS_REGEX = + /(?/g; +const AMPERSAND_REGEX = /&(?!#?[0-9A-Za-z]+;)/g; +const DEFAULT_PARTIAL_LINK_URL = "#"; + +type CheckboxToken = { + type: "checkbox"; + checked: boolean; + raw: string; +}; + +type Token = MarkedToken | CheckboxToken; + +type AnyToken = Token | DefaultToken; + +type PotentiallyPartialToken = Relax< + | Tokens.Text + | Tokens.Paragraph + | Tokens.Heading + | Tokens.Blockquote + | Tokens.ListItem + | Tokens.TableCell + | Tokens.Code +>; + export type MarkdownComponents = { /** * The component used to render paragraphs. @@ -165,9 +208,6 @@ export type MarkdownComponents = { * * {items.map((item, index) => ( *
  • - * {item.checked !== undefined && ( - * {" "} - * )} * {item.children} *
  • * ))} @@ -300,6 +340,7 @@ export interface MarkdownComponentsCodeBlockProps { export interface MarkdownProps extends ComponentPropsWithSlot<"div"> { content: string; + partial?: boolean; components?: Partial; } @@ -384,14 +425,7 @@ const defaultComponents: MarkdownComponents = { return ( {items.map((item, index) => ( -
  • - {item.checked !== undefined && ( - <> - {" "} - - )} - {item.children} -
  • +
  • {item.children}
  • ))}
    ); @@ -402,11 +436,15 @@ const defaultComponents: MarkdownComponents = { }; export const Markdown = forwardRef( - ({ content, components, asChild, ...props }, forwardedRef) => { + ({ content, partial, components, asChild, ...props }, forwardedRef) => { const Component = asChild ? Slot : "div"; const tokens = useMemo(() => { - return new Lexer().lex(content); - }, [content]); + if (!content) { + return []; + } + + return partial ? tokenizePartial(content) : tokenize(content); + }, [content, partial]); return ( @@ -416,6 +454,7 @@ export const Markdown = forwardRef( token={token} key={index} components={components} + partial={partial} /> ); })} @@ -431,6 +470,7 @@ const MemoizedMarkdownToken = memo( }: { token: Token; components?: Partial; + partial?: boolean; }) => { return ; }, @@ -438,15 +478,29 @@ const MemoizedMarkdownToken = memo( const previousToken = previousProps.token; const nextToken = nextProps.token; - if (previousToken.raw.length !== nextToken.raw.length) { + // 1️⃣ Start with the fastest comparisons + if ( + previousToken.type !== nextToken.type || + previousProps.partial !== nextProps.partial + ) { return false; } - if (previousToken.type !== nextToken.type) { + let previousContent = previousToken.raw; + let nextContent = nextToken.raw; + + if ("text" in previousToken && "text" in nextToken) { + previousContent = previousToken.text; + nextContent = nextToken.text; + } + + // 2️⃣ Then only compare the tokens' content lengths first + if (previousContent.length !== nextContent.length) { return false; } - return previousToken.raw === nextToken.raw; + // 3️⃣ And finally compare the actual tokens' content + return previousContent === nextContent; } ); @@ -457,11 +511,6 @@ export function MarkdownToken({ token: Token; components: Partial | undefined; }) { - // Marked.js supports generic tokens, but we don't use them. - if (!isMarkedToken(token)) { - return null; - } - switch (token.type) { case "escape": { return token.text; @@ -558,12 +607,13 @@ export function MarkdownToken({ case "code": { let language: string | undefined = undefined; if (token.lang !== undefined) { - language = token.lang.match(/^\S*/)?.[0] ?? undefined; + language = + token.lang.match(TRAILING_NON_WHITESPACE_REGEX)?.[0] ?? undefined; } const CodeBlock = components?.CodeBlock ?? defaultComponents.CodeBlock; - return ; + return ; } case "blockquote": { @@ -583,14 +633,29 @@ export function MarkdownToken({ case "list": { const List = components?.List ?? defaultComponents.List; const items: MarkdownComponentsListItem[] = token.items.map((item) => { + let tokens = item.tokens; + + if (item.task) { + tokens = [ + { + type: "checkbox", + checked: Boolean(item.checked), + raw: `[${item.checked ? "x" : " "}]`, + }, + ...tokens, + ]; + } + return { checked: item.task ? item.checked : undefined, children: ( 0 ? item.loose : false + } /> ), }; @@ -603,6 +668,14 @@ export function MarkdownToken({ return ; } + case "checkbox": { + return ( + <> + {" "} + + ); + } + case "table": { const Table = components?.Table ?? defaultComponents.Table; const headings: MarkdownComponentsTableCell[] = token.header.map( @@ -646,8 +719,10 @@ export function MarkdownToken({ return ; } - // HTML elements/tokens are not supported (yet). - case "html": + case "html": { + return parseHtmlEntities(token.text); + } + default: { return null; } @@ -659,82 +734,664 @@ function MarkdownTokens({ components, normalizeToBlockTokens = false, }: { - tokens: Token[]; + tokens: AnyToken[]; components: Partial | undefined; normalizeToBlockTokens?: boolean; }) { - const normalizedTokens: Token[] = []; + assertTokens(tokens); + + let normalizedTokens: Token[] = []; if (normalizeToBlockTokens) { + let leadingCheckboxToken = + tokens[0]?.type === "checkbox" ? tokens[0] : null; + for (let i = 0; i < tokens.length; i++) { const token = tokens[i]!; switch (token.type) { case "text": { - // Wrap consecutive text tokens into a paragraph - const texts: Tokens.Text[] = [token as Tokens.Text]; + const paragraphTextTokens: Tokens.Text[] = [token]; + + // Wrap consecutive text tokens into a paragraph. while (i + 1 < tokens.length && tokens[i + 1]!.type === "text") { i++; - texts.push(tokens[i] as Tokens.Text); + paragraphTextTokens.push(tokens[i] as Tokens.Text); } + const paragraphRaw = paragraphTextTokens + .map((text) => text.raw) + .join(""); + const paragraphText = paragraphTextTokens + .map((text) => text.text) + .join(""); + + // When wrapping loose task list items into paragraphs, we need to + // move the checkbox into the first paragraph. normalizedTokens.push({ type: "paragraph", - tokens: texts, - raw: texts.map((text) => text.raw).join(""), - text: texts.map((text) => text.text).join(""), + tokens: leadingCheckboxToken + ? [leadingCheckboxToken, ...paragraphTextTokens] + : paragraphTextTokens, + raw: paragraphRaw, + text: paragraphText, } satisfies Tokens.Paragraph); + leadingCheckboxToken = null; break; } + case "checkbox": + break; + default: { normalizedTokens.push(token); } } } + } else { + normalizedTokens = tokens; } - return tokens.map((token, index) => ( + return normalizedTokens.map((token, index) => ( )); } -const markedTokenTypes = [ - "blockquote", - "br", - "code", - "codespan", - "def", - "del", - "em", - "escape", - "heading", - "hr", - "html", - "image", - "link", - "list", - "list_item", - "paragraph", - "space", - "strong", - "table", - "text", -] as const satisfies MarkedToken["type"][]; - -function isMarkedToken(token: unknown): token is MarkedToken { +/** + * Marked.js' `Token` union type includes a `Generic` token type which is + * too broad and makes narrowing difficult, we don't use generic/custom tokens + * so we can assert the `Generic` type away. + */ +function assertTokens(_: AnyToken): asserts _ is Token; +function assertTokens(_: AnyToken[]): asserts _ is Token[]; +function assertTokens(_: AnyToken | AnyToken[]): asserts _ is Token | Token[] {} + +function isBlockToken( + token: Token | Tokens.Generic +): token is + | Tokens.Paragraph + | Tokens.Heading + | Tokens.Blockquote + | Tokens.ListItem { return ( - typeof token === "object" && - token !== null && - "type" in token && - markedTokenTypes.includes(token.type as MarkedToken["type"]) + token.type === "paragraph" || + token.type === "heading" || + token.type === "blockquote" || + token.type === "list_item" + ); +} + +function tokenize(markdown: string) { + return new Lexer().lex(markdown) as Token[]; +} + +function tokenizePartial(markdown: string) { + const preprocessedContent = trimPartialMarkdown(normalizeNewlines(markdown)); + + const tokens = tokenize(preprocessedContent); + + try { + return completePartialTokens(tokens); + } catch { + return tokens; + } +} + +/** + * Find the last partial token that we could potentially complete. + */ +function findPotentiallyPartialToken( + tokens: AnyToken[], + parentToken?: PotentiallyPartialToken +): PotentiallyPartialToken | undefined { + if (tokens.length === 0) { + return parentToken; + } + + assertTokens(tokens); + + const lastIndex = tokens.length - 1; + let lastToken = tokens[lastIndex]!; + + if (lastToken.type === "space") { + const penultimateToken = tokens[lastIndex - 1]; + + if (!penultimateToken) { + return parentToken; + } + + lastToken = penultimateToken; + } + + if (lastToken.type === "list") { + const listToken = lastToken; + const lastListItem = listToken.items[listToken.items.length - 1]; + + if (!lastListItem) { + return parentToken; + } + + const lastListItemTokens = lastListItem.tokens as Token[]; + + // List items containing empty lines are handled differently, + // instead of using the list item's tokens, we use the last one + // if it's a text or block token. + if ( + lastListItemTokens.some((token) => token.type === "space") && + lastListItemTokens.length > 0 + ) { + const lastListItemLastToken = + lastListItemTokens[lastListItemTokens.length - 1]; + + if (lastListItemLastToken) { + if (lastListItemLastToken.type === "text") { + return lastListItemLastToken; + } + + if (isBlockToken(lastListItemLastToken)) { + return findPotentiallyPartialToken( + lastListItemLastToken.tokens, + lastListItemLastToken + ); + } + + return undefined; + } + } + + return findPotentiallyPartialToken(lastListItem.tokens, lastListItem); + } + + if (lastToken.type === "table") { + const tableToken = lastToken; + const lastTableRow = tableToken.rows[tableToken.rows.length - 1]; + + if (!lastTableRow) { + return parentToken; + } + + // Marked.js creates all cells in advance when creating rows, + // we want the cell where the end currently is. + const firstEmptyTableCellIndex = lastTableRow.findIndex( + (cell) => cell.tokens.length === 0 + ); + const lastNonEmptyTableCell = + firstEmptyTableCellIndex === -1 + ? undefined + : firstEmptyTableCellIndex === 0 + ? lastTableRow[firstEmptyTableCellIndex] + : lastTableRow[firstEmptyTableCellIndex - 1]; + + if (!lastNonEmptyTableCell) { + return parentToken; + } + + return findPotentiallyPartialToken( + lastNonEmptyTableCell.tokens, + lastNonEmptyTableCell + ); + } + + if (isBlockToken(lastToken)) { + return findPotentiallyPartialToken(lastToken.tokens, lastToken); + } + + return parentToken; +} + +function normalizeNewlines(string: string) { + return string.replace(NEWLINE_REGEX, "\n"); +} + +/** + * Trim a partial Markdown string to avoid incomplete tokens. + */ +function trimPartialMarkdown(markdown: string) { + const lines = markdown.split("\n"); + + if (lines.length === 0) { + return markdown; + } + + // If the last line contains a single non-whitespace character, + // we can remove it for now. + const [singleCharacterMatch] = + lines[lines.length - 1]!.match(SINGLE_CHARACTER_REGEX) ?? []; + + if (singleCharacterMatch) { + lines[lines.length - 1] = lines[lines.length - 1]!.slice( + 0, + -singleCharacterMatch.length + ); + + return lines.join("\n"); + } + + // If the last line ends with partial syntax, we can remove it for now. + const [bufferedCharactersMatch] = + lines[lines.length - 1]!.match(BUFFERED_CHARACTERS_REGEX) ?? []; + + if (bufferedCharactersMatch) { + lines[lines.length - 1] = lines[lines.length - 1]!.slice( + 0, + -bufferedCharactersMatch.length + ); + + return lines.join("\n"); + } + + return markdown; +} + +/** + * Optimistically complete a Markdown string of inline content: + * + * - Bold, italic, strikethrough, and inline code + * - Links + * + * Remove any remaining partial content: + * + * - Emoji + */ +function completePartialInlineMarkdown( + markdown: string, + options: { allowLinksImages?: boolean } = {} +): string { + const stack: { string: string; length: number; index: number }[] = []; + const allowLinksImages = options.allowLinksImages ?? true; + let completedMarkdown = markdown; + + // Trim any partial emoji. + // + // We do this here rather than in `trimPartialMarkdown` because + // `trimPartialMarkdown` needs to run before Marked.js to prevent + // it from parsing partial Markdown syntax, emojis won't be parsed + // by Marked.js so we can trim them here to be a bit more efficient. + const partialEmojiMatch = completedMarkdown.match(PARTIAL_EMOJI_REGEX); + + if (partialEmojiMatch) { + const partialEmoji = partialEmojiMatch[0]; + completedMarkdown = completedMarkdown.slice(0, -partialEmoji.length); + + // If the removed partial emoji contains a special modifier (VS16 or keycap), + // we also remove the preceding code point. + if (partialEmoji.includes("\uFE0F") || partialEmoji.includes("\u20E3")) { + const codepoints = Array.from(completedMarkdown); + + if (codepoints.length > 0) { + completedMarkdown = codepoints.slice(0, -1).join(""); + } + } + } + + // Move forward through the string to collect delimiters. + for (let i = 0; i < completedMarkdown.length; i++) { + const character = completedMarkdown[i]!; + const isEscaped = i > 0 ? completedMarkdown[i - 1] === "\\" : false; + + if (isEscaped) { + continue; + } + + if (character === "`") { + const lastDelimiter = stack[stack.length - 1]; + const isClosingPreviousDelimiter = + lastDelimiter?.string === "`" && i > lastDelimiter.index; + + if (isClosingPreviousDelimiter) { + // If the delimiter is closing a previous delimiter, + // we remove it from the stack. + stack.pop(); + } else { + const characterAfterDelimiter = completedMarkdown[i + 1]; + + // If the delimiter is opening and is followed by a + // non-whitespace character, we add it to the stack. + if ( + characterAfterDelimiter && + !WHITESPACE_REGEX.test(characterAfterDelimiter) + ) { + stack.push({ string: "`", length: 1, index: i }); + } + } + + continue; + } + + if (character === "*" || character === "_" || character === "~") { + const isInsideInlineCode = stack[stack.length - 1]?.string === "`"; + + let j = i; + while ( + j < completedMarkdown.length && + completedMarkdown[j] === character + ) { + j++; + } + const consecutiveDelimiterCharacters = j - i; + + // Delimiters inside inline code shouldn't be closed so we skip them. + if (isInsideInlineCode) { + i += consecutiveDelimiterCharacters - 1; + + continue; + } + + let remainingConsecutiveDelimiterCharacters = + consecutiveDelimiterCharacters; + let consecutiveDelimiterCharacterIndex = 0; + + while (remainingConsecutiveDelimiterCharacters > 0) { + const lastDelimiter = stack[stack.length - 1]; + + if (!lastDelimiter || lastDelimiter.string[0] !== character) { + break; + } + + // We close as many matching open delimiters as possible from the stack. + if (remainingConsecutiveDelimiterCharacters >= lastDelimiter.length) { + stack.pop(); + remainingConsecutiveDelimiterCharacters -= lastDelimiter.length; + consecutiveDelimiterCharacterIndex += lastDelimiter.length; + + continue; + } + + break; + } + + if (remainingConsecutiveDelimiterCharacters > 0) { + // If there are any unmatched delimiters at the end of the string, + // we can remove them from the string. + if (i + consecutiveDelimiterCharacters >= completedMarkdown.length) { + completedMarkdown = completedMarkdown.slice( + 0, + completedMarkdown.length - remainingConsecutiveDelimiterCharacters + ); + + break; + } + + const characterAfterDelimiters = + completedMarkdown[i + consecutiveDelimiterCharacters]; + + if ( + characterAfterDelimiters && + !WHITESPACE_REGEX.test(characterAfterDelimiters) + ) { + let delimiterStartIndex = i + consecutiveDelimiterCharacterIndex; + + // If there's an odd number of unmatched delimiters, + // we match a single-character delimiter. + if (remainingConsecutiveDelimiterCharacters % 2 === 1) { + stack.push({ + string: character, + length: 1, + index: delimiterStartIndex, + }); + delimiterStartIndex += 1; + remainingConsecutiveDelimiterCharacters -= 1; + } + + // If there are any unmatched delimiters remaining, + // we match double-character delimiters. + while (remainingConsecutiveDelimiterCharacters >= 2) { + stack.push({ + string: character + character, + length: 2, + index: delimiterStartIndex, + }); + delimiterStartIndex += 2; + remainingConsecutiveDelimiterCharacters -= 2; + } + } + } + + i += consecutiveDelimiterCharacters - 1; + + continue; + } + } + + if (allowLinksImages) { + const partialLinkImageMatch = completedMarkdown.match( + PARTIAL_LINK_IMAGE_REGEX + ); + + if (partialLinkImageMatch) { + const linkImageStartIndex = partialLinkImageMatch.index!; + const linkImageEndIndex = + linkImageStartIndex + partialLinkImageMatch[0].length; + + const isInsideInlineCode = stack.some( + (delimiter) => + delimiter.string === "`" && delimiter.index < linkImageStartIndex + ); + + if (!isInsideInlineCode) { + const partialLinkImageContent = partialLinkImageMatch[0]; + const { + text: partialLinkText, + url: partialLinkUrl, + image: isImage, + } = partialLinkImageMatch.groups!; + + if (isImage) { + // We can't optimistically complete images, so we remove them until they are complete. + completedMarkdown = completedMarkdown.slice( + 0, + -partialLinkImageContent.length + ); + } else { + // We can remove delimiters from the stack that are inside the completed link, + // since they are now closed. + for (let i = stack.length - 1; i >= 0; i--) { + const delimiter = stack[i]!; + + if ( + delimiter.index >= linkImageStartIndex && + delimiter.index < linkImageEndIndex + ) { + stack.splice(i, 1); + } + } + + const completedLinkText = partialLinkText + ? partialLinkUrl + ? // If there's a partial URL, the text is already completed. + partialLinkText + : // Otherwise, we complete the text and its potential nested elements. + completePartialInlineMarkdown(partialLinkText, { + // Links/images cannot be nested. + allowLinksImages: false, + }) + : ""; + const completedLinkUrl = + partialLinkUrl && + !WHITESPACE_REGEX.test(partialLinkUrl) && + isUrl(partialLinkUrl) + ? // We only use the partial URL if it's valid. + partialLinkUrl + : DEFAULT_PARTIAL_LINK_URL; + const completedLink = `[${completedLinkText}](${completedLinkUrl})`; + + completedMarkdown = completedMarkdown.slice( + 0, + -partialLinkImageContent.length + ); + completedMarkdown += completedLink; + } + } + } + } + + // Move through the stack to close open formatting tokens. + for (let i = stack.length - 1; i >= 0; i--) { + const delimiter = stack[i]!; + + // If the open token is at the end of the string, + // we can't close it yet so we remove it. + if (delimiter.index + delimiter.length >= completedMarkdown.length) { + completedMarkdown = completedMarkdown.slice(0, delimiter.index); + continue; + } + + // Bold, italic, and strikethrough cannot end with whitespace so + // we trim their content before closing them. + if (delimiter.string !== "`") { + completedMarkdown = completedMarkdown.trimEnd(); + } + + // We can close that open token at this point. + completedMarkdown += delimiter.string; + } + + return completedMarkdown; +} + +/** + * Optimistically complete a Markdown string of a table. + */ +function completePartialTableMarkdown(markdown: string): string | undefined { + const tableLines = markdown.split("\n"); + + if (tableLines.length === 0) { + return undefined; + } + + const tableHeader = tableLines[0]!; + + if (tableHeader === "|") { + return undefined; + } + + const tableHeadings = tableHeader + .split("|") + .map((cell) => cell.trim()) + .filter((cell) => cell !== ""); + + if (tableHeadings.length === 0) { + return undefined; + } + + // If the last header cell is partial, it might also contain partial elements. + if (!tableHeader.endsWith("|")) { + const lastTableHeading = tableHeadings[tableHeadings.length - 1]!; + const completedLastTableHeading = + completePartialInlineMarkdown(lastTableHeading); + + tableHeadings[tableHeadings.length - 1] = completedLastTableHeading; + } + + return `| ${tableHeadings.join(" | ")} |\n| ${tableHeadings.map(() => "---").join(" | ")} |`; +} + +function completePartialTokens(tokens: Token[]) { + const potentiallyPartialToken = findPotentiallyPartialToken(tokens); + + if (!potentiallyPartialToken) { + return tokens; + } + + if ( + potentiallyPartialToken.type === "paragraph" || + potentiallyPartialToken.type === "text" + ) { + // Marked.js only creates tables when the table header and its + // separator below are complete. + // + // We optimistically create tables sooner if the current text looks + // like a partial table header. + if (PARTIAL_TABLE_HEADER_REGEX.test(potentiallyPartialToken.raw)) { + const completedTableMarkdown = completePartialTableMarkdown( + potentiallyPartialToken.raw + ); + + if (completedTableMarkdown) { + // We optimistically complete the table as a string then re-tokenize + // it to get an optimistically complete table token. + const completedTable = tokenize(completedTableMarkdown)[0] as + | Tokens.Table + | undefined; + + if (completedTable) { + // We replace the paragraph/text by the optimistically completed table. + const table = potentiallyPartialToken as unknown as Tokens.Table; + + table.type = "table"; + table.header = completedTable.header; + table.align = completedTable.align; + table.rows = completedTable.rows; + + return tokens; + } + } else { + // Otherwise, we hide it for now. + potentiallyPartialToken.text = ""; + potentiallyPartialToken.tokens = []; + } + } + } + + if (potentiallyPartialToken.type === "list_item") { + const listItem = potentiallyPartialToken; + const listItemTokens = listItem.tokens as Token[]; + + // Marked.js only turns list items into tasks when the list marker + // and the task checkbox are complete and followed by a space. (e.g. "- [x] ") + // + // We optimistically mark list items as tasks sooner, + // whenever a list item starts with "- [", "- [x", etc. + if ( + !listItem.task && + listItemTokens.length === 1 && + listItemTokens[0]!.type === "text" + ) { + const listItemText = listItemTokens[0]; + const checkboxMatch = listItemText.text.match(LIST_ITEM_CHECKBOX_REGEX); + + if (checkboxMatch) { + listItem.task = true; + + if (checkboxMatch[1] === "x") { + listItem.checked = true; + } else { + listItem.checked = false; + } + + listItem.text = ""; + listItem.tokens = []; + } + } + } + + if (potentiallyPartialToken.text.length === 0) { + return tokens; + } + + // We optimistically complete inline content as a string then re-tokenize + // it to get optimistically complete tokens. + const completedMarkdown = completePartialInlineMarkdown( + potentiallyPartialToken.text ); + const completedMarkdownTokens = + (tokenize(completedMarkdown)[0] as Tokens.Paragraph | undefined)?.tokens ?? + []; + + potentiallyPartialToken.text = completedMarkdown; + potentiallyPartialToken.tokens = completedMarkdownTokens; + + return tokens; } function parseHtmlEntities(input: string) { const document = new DOMParser().parseFromString( - `${input}`, + `${input + // Prevent some characters from being interpreted as markup. + .replace(AMPERSAND_REGEX, "&") + .replace(LEFT_ANGLE_BRACKET_REGEX, "<") + .replace(RIGHT_ANGLE_BRACKET_REGEX, ">")}`, "text/html" ); diff --git a/packages/liveblocks-react-ui/src/primitives/__tests__/Markdown.test.tsx b/packages/liveblocks-react-ui/src/primitives/__tests__/Markdown.test.tsx index 8cac26aef80..e17a74447cf 100644 --- a/packages/liveblocks-react-ui/src/primitives/__tests__/Markdown.test.tsx +++ b/packages/liveblocks-react-ui/src/primitives/__tests__/Markdown.test.tsx @@ -8,1076 +8,2598 @@ describe("Markdown", () => { afterEach(() => { cleanup(); }); - test.each([ + + test("should rerender when the content changes", () => { + const { getByTestId, rerender } = render( + + ); + + const root = getByTestId("markdown"); + + expect(root.textContent).toBe("A"); + expect(root.querySelector("a")).not.toBeInTheDocument(); + + rerender( + + ); + + expect(root.textContent).toBe("A link."); + expect(root.querySelector("a")).toHaveAttribute( + "href", + "https://liveblocks.io" + ); + }); + + test("should render empty content", () => { { - description: "paragraphs", - content: dedent` - A paragraph. + const { getByTestId, unmount } = render( + // @ts-expect-error - Testing a missing `content` prop + + ); - Another paragraph which - spans multiple lines. - `, - assertions: (element) => { - const paragraphs = element.querySelectorAll("p"); + const root = getByTestId("markdown"); + expect(root.innerHTML).toBe(""); + + unmount(); + } - expect(paragraphs).toHaveLength(2); - expect(paragraphs[0]).toHaveTextContent("A paragraph."); - expect(paragraphs[1]).toHaveTextContent( - "Another paragraph which spans multiple lines." - ); - }, - }, { - description: "headings", - content: dedent` - # Heading 1 + const { getByTestId, unmount } = render( + + ); - ## Heading 2 + const root = getByTestId("markdown"); + expect(root.innerHTML).toBe(""); - ### Heading 3 + unmount(); + } - #### Heading 4 + { + const { getByTestId, unmount } = render( + + ); - ##### Heading 5 + const root = getByTestId("markdown"); + expect(root.innerHTML).toBe(""); - ###### Heading 6 + unmount(); + } + }); - Alternate heading 1 - =============== + describe("should render…", () => { + function assert(content: string, assertions: (root: HTMLElement) => void) { + const { getByTestId, unmount } = render( + + ); - Alternate heading 2 - -------------- - `, - assertions: (element) => { - const headings = element.querySelectorAll("h1, h2, h3, h4, h5, h6"); + assertions(getByTestId("markdown")); + unmount(); + } - expect(headings).toHaveLength(8); - expect(headings[0]).toHaveTextContent("Heading 1"); - expect(headings[1]).toHaveTextContent("Heading 2"); - expect(headings[2]).toHaveTextContent("Heading 3"); - expect(headings[3]).toHaveTextContent("Heading 4"); - expect(headings[4]).toHaveTextContent("Heading 5"); - expect(headings[5]).toHaveTextContent("Heading 6"); - expect(headings[6]).toHaveTextContent("Alternate heading 1"); - expect(headings[7]).toHaveTextContent("Alternate heading 2"); - }, - }, - { - description: "bold text", - content: dedent` - **Bold** and __bold__. - `, - assertions: (element) => { - const strongs = element.querySelectorAll("strong"); - - expect(strongs).toHaveLength(2); - expect(strongs[0]).toHaveTextContent("Bold"); - expect(strongs[1]).toHaveTextContent("bold"); - }, - }, - { - description: "italic text", - content: dedent` - *Italic* and _italic_. - `, - assertions: (element) => { - const ems = element.querySelectorAll("em"); - - expect(ems).toHaveLength(2); - expect(ems[0]).toHaveTextContent("Italic"); - expect(ems[1]).toHaveTextContent("italic"); - }, - }, - { - description: "strikethrough text", - content: dedent` - ~~Strikethrough~~. - `, - assertions: (element) => { - const dels = element.querySelectorAll("del"); + test("paragraphs", () => { + assert( + ` + A paragraph. + + Another paragraph which + spans multiple lines. + `, + (root) => { + const paragraphs = root.querySelectorAll("p"); + + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]?.textContent).toBe("A paragraph."); + expect(paragraphs[1]?.textContent).toBe( + "Another paragraph which\nspans multiple lines." + ); + } + ); + }); + + test("headings", () => { + assert( + ` + # Heading 1 + + ## Heading 2 + + ### Heading 3 + + #### Heading 4 + + ##### Heading 5 + + ###### Heading 6 + + Alternate heading 1 + =============== + + Alternate heading 2 + -------------- + `, + (root) => { + const headings = root.querySelectorAll("h1, h2, h3, h4, h5, h6"); + + expect(headings).toHaveLength(8); + expect(headings[0]?.textContent).toBe("Heading 1"); + expect(headings[1]?.textContent).toBe("Heading 2"); + expect(headings[2]?.textContent).toBe("Heading 3"); + expect(headings[3]?.textContent).toBe("Heading 4"); + expect(headings[4]?.textContent).toBe("Heading 5"); + expect(headings[5]?.textContent).toBe("Heading 6"); + expect(headings[6]?.textContent).toBe("Alternate heading 1"); + expect(headings[7]?.textContent).toBe("Alternate heading 2"); + } + ); + }); + + test("bold text", () => { + assert( + ` + **Bold** and __bold__. + `, + (root) => { + const strongs = root.querySelectorAll("strong"); + + expect(strongs).toHaveLength(2); + expect(strongs[0]?.textContent).toBe("Bold"); + expect(strongs[1]?.textContent).toBe("bold"); + } + ); + }); + + test("italic text", () => { + assert( + ` + *Italic* and _italic_. + `, + (root) => { + const ems = root.querySelectorAll("em"); + + expect(ems).toHaveLength(2); + expect(ems[0]?.textContent).toBe("Italic"); + expect(ems[1]?.textContent).toBe("italic"); + } + ); + }); + + test("strikethrough text", () => { + assert( + ` + ~~Strikethrough~~ and ~strikethrough~. + `, + (root) => { + const dels = root.querySelectorAll("del"); + + expect(dels).toHaveLength(2); + expect(dels[0]?.textContent).toBe("Strikethrough"); + expect(dels[1]?.textContent).toBe("strikethrough"); + } + ); + }); + + test("inline code", () => { + assert( + ` + Inline \`code\`. + `, + (root) => { + const codes = root.querySelectorAll("code"); + + expect(codes).toHaveLength(1); + expect(codes[0]?.textContent).toBe("code"); + } + ); + }); + + test("links", () => { + assert( + ` + A [link](https://www.liveblocks.io), [another one](/docs "With a title"), + https://www.liveblocks.io, and . + `, + (root) => { + const links = root.querySelectorAll("a"); + + expect(links).toHaveLength(4); + expect(links[0]?.textContent).toBe("link"); + expect(links[0]).toHaveAttribute("href", "https://www.liveblocks.io"); + expect(links[1]?.textContent).toBe("another one"); + expect(links[1]).toHaveAttribute("href", "/docs"); + expect(links[2]?.textContent).toBe("https://www.liveblocks.io"); + expect(links[2]).toHaveAttribute("href", "https://www.liveblocks.io"); + expect(links[3]?.textContent).toBe("https://www.liveblocks.io"); + expect(links[3]).toHaveAttribute("href", "https://www.liveblocks.io"); + } + ); + }); - expect(dels).toHaveLength(1); - expect(dels[0]).toHaveTextContent("Strikethrough"); - }, - }, - { - description: "inline code", - content: dedent` - Inline \`code\`. + test("nested inline elements", () => { + assert( + ` + **Bold _italic \`code\`_**, a **bold [link](https://www.liveblocks.io)**, and [one with **bold \`code\`**](#docs "With a title"). `, - assertions: (element) => { - const codes = element.querySelectorAll("code"); + (root) => { + const firstInlineElement = root.querySelector("strong:nth-child(1)"); - expect(codes).toHaveLength(1); - expect(codes[0]).toHaveTextContent("code"); - }, - }, - { - description: "links", - content: dedent` - A [link](https://www.liveblocks.io), [another one](/docs "With a title"), - https://www.liveblocks.io, and . - `, - assertions: (element) => { - const links = element.querySelectorAll("a"); - - expect(links).toHaveLength(4); - expect(links[0]).toHaveAttribute("href", "https://www.liveblocks.io"); - expect(links[1]).toHaveAttribute("href", "/docs"); - expect(links[2]).toHaveAttribute("href", "https://www.liveblocks.io"); - expect(links[3]).toHaveAttribute("href", "https://www.liveblocks.io"); - }, - }, - { - description: "ordered lists", - content: dedent` - 1. A list item - 2. Another list item - 3. Yet another list item - `, - assertions: (element) => { - const list = element.querySelector("ol"); - const listItems = list?.querySelectorAll("li"); - - expect(list).not.toBeNull(); - expect(listItems).toHaveLength(3); - expect(listItems?.[0]).toHaveTextContent("A list item"); - expect(listItems?.[1]).toHaveTextContent("Another list item"); - expect(listItems?.[2]).toHaveTextContent("Yet another list item"); - }, - }, - { - description: "unordered lists", - content: dedent` - - A list item - - Another list item - - Yet another list item + expect(firstInlineElement?.textContent).toBe("Bold italic code"); + expect(firstInlineElement?.querySelector("em")?.textContent).toBe( + "italic code" + ); + expect(firstInlineElement?.querySelector("code")?.textContent).toBe( + "code" + ); - * A list item - * Another list item - * Yet another list item + const secondInlineElement = root.querySelector("strong:nth-child(2)"); - + A list item - + Another list item - + Yet another list item - `, - assertions: (element) => { - const lists = element.querySelectorAll("ul"); + expect(secondInlineElement?.textContent).toBe("bold link"); + expect(secondInlineElement?.querySelector("a")?.textContent).toBe( + "link" + ); + expect(secondInlineElement?.querySelector("a")).toHaveAttribute( + "href", + "https://www.liveblocks.io" + ); + + const thirdInlineElement = root.querySelector( + "a[title='With a title']" + ); + expect(thirdInlineElement?.textContent).toBe("one with bold code"); + expect(thirdInlineElement).toHaveAttribute("href", "#docs"); + expect(thirdInlineElement?.querySelector("strong")?.textContent).toBe( + "bold code" + ); + expect(thirdInlineElement?.querySelector("code")?.textContent).toBe( + "code" + ); + } + ); - lists.forEach((list) => { - const listItems = list.querySelectorAll("li"); + assert( + ` + ***Italic bold***, ****double bold****, and *****italic double bold*****. + `, + (root) => { + const elements = root.querySelectorAll("p > *"); - expect(listItems).toHaveLength(3); - expect(listItems?.[0]).toHaveTextContent("A list item"); - expect(listItems?.[1]).toHaveTextContent("Another list item"); - expect(listItems?.[2]).toHaveTextContent("Yet another list item"); - }); - }, - }, - { - description: "task lists", - content: dedent` - - [ ] A list item - - [x] Another list item - - [ ] Yet another list item - `, - assertions: (element) => { - const list = element.querySelector("ul"); - const listItems = list?.querySelectorAll("li"); - - expect(list).not.toBeNull(); - expect(listItems).toHaveLength(3); - expect(listItems?.[0]).toHaveTextContent("A list item"); - expect( - listItems?.[0]?.querySelector("input[type='checkbox']") - ).not.toBeChecked(); - expect(listItems?.[1]).toHaveTextContent("Another list item"); - expect( - listItems?.[1]?.querySelector("input[type='checkbox']") - ).toBeChecked(); - expect(listItems?.[2]).toHaveTextContent("Yet another list item"); - expect( - listItems?.[2]?.querySelector("input[type='checkbox']") - ).not.toBeChecked(); - }, - }, - { - description: "mixed lists", - content: dedent` - - A list item - 1. A nested list item - - Another nested list item - - [ ] A deeply nested list item - - Another list item - 1. A nested list item - 2. [x] Another nested list item - `, - assertions: (element) => { - const rootList = element.querySelector(":scope > ul"); - expect(rootList).toBeInTheDocument(); - - const rootListItems = element.querySelectorAll(":scope > ul > li"); - expect(rootListItems).toHaveLength(2); - - // - A list item - // 1. A nested list item - // - Another nested list item - // - [ ] A deeply nested list item - const firstNestedLists = element?.querySelectorAll( - ":scope > ul > li:nth-child(1) > :is(ul, ol)" - ); - expect(firstNestedLists).toHaveLength(2); - - // 1. A nested list item - const firstNestedFirstList = firstNestedLists?.[0]; - expect(firstNestedFirstList?.tagName).toBe("OL"); - expect(firstNestedFirstList?.childNodes).toHaveLength(1); - expect(firstNestedFirstList?.childNodes[0]?.textContent).toBe( - "A nested list item" - ); - - // - Another nested list item - // - [ ] A deeply nested list item - const firstNestedSecondList = firstNestedLists?.[1]; - expect(firstNestedSecondList?.tagName).toBe("UL"); - expect(firstNestedSecondList?.childNodes).toHaveLength(1); - expect( - firstNestedSecondList?.childNodes[0]?.childNodes[0] - ).toHaveTextContent("Another nested list item"); - - // - [ ] A deeply nested list item - const firstNestedSecondListFirstNestedList = firstNestedSecondList - ?.childNodes[0]?.childNodes[1] as HTMLElement | null; - expect(firstNestedSecondListFirstNestedList).toHaveTextContent( - "A deeply nested list item" - ); - expect(firstNestedSecondListFirstNestedList?.tagName).toBe("UL"); - expect( - firstNestedSecondListFirstNestedList?.querySelector( - "input[type='checkbox']" - ) - ).not.toBeChecked(); - - // - Another list item - // 1. A nested list item - // 2. [x] Another nested list item - const secondNestedList = element?.querySelector( - ":scope > ul > li:nth-child(2) > :is(ul, ol)" - ); - expect(secondNestedList?.tagName).toBe("OL"); - expect(secondNestedList?.childNodes).toHaveLength(2); - expect(secondNestedList?.childNodes[0]).toHaveTextContent( - "A nested list item" - ); - expect(secondNestedList?.childNodes[1]).toHaveTextContent( - "Another nested list item" - ); - expect( - ( - secondNestedList?.childNodes[1] as HTMLElement | null - )?.querySelector("input[type='checkbox']") - ).toBeChecked(); - }, - }, - { - description: "loose lists", - content: dedent` - - A list item + expect(elements).toHaveLength(3); + + expect(elements[0]?.textContent).toBe("Italic bold"); + expect(elements[0]?.tagName).toBe("EM"); + expect(elements[0]?.querySelector("strong")).toBeInTheDocument(); + + expect(elements[1]?.textContent).toBe("double bold"); + expect(elements[1]?.tagName).toBe("STRONG"); + expect(elements[1]?.querySelector("strong")).toBeInTheDocument(); + + expect(elements[2]?.textContent).toBe("italic double bold"); + expect(elements[2]?.tagName).toBe("EM"); + expect(elements[2]?.querySelectorAll("strong")).toHaveLength(2); + } + ); - - Another list item with + assert( + ` + Inline \`code without **bold**, _italic_, ~~strikethrough~~, and [a link](https://www.liveblocks.io)\`. + `, + (root) => { + const code = root.querySelector("code"); - multiple paragraphs. + expect(code?.textContent).toBe( + "code without **bold**, _italic_, ~~strikethrough~~, and [a link](https://www.liveblocks.io)" + ); + expect(code?.querySelector("strong")).not.toBeInTheDocument(); + expect(code?.querySelector("em")).not.toBeInTheDocument(); + expect(code?.querySelector("del")).not.toBeInTheDocument(); + expect(code?.querySelector("a")).not.toBeInTheDocument(); + } + ); + }); - - [x] A task list item with + test("ordered lists", () => { + assert( + ` + 1. A list item + 2. Another list item + 3. Yet another list item + `, + (root) => { + const list = root.querySelector("ol"); + const listItems = list?.querySelectorAll("li"); - > a quote and a code block + expect(list).not.toBeNull(); + expect(listItems).toHaveLength(3); + expect(listItems?.[0]?.textContent).toBe("A list item"); + expect(listItems?.[1]?.textContent).toBe("Another list item"); + expect(listItems?.[2]?.textContent).toBe("Yet another list item"); + } + ); + // Ordered lists can have arbitrary starting indices. + assert( + ` + 1. A numbered list item + - A "nested" list item + - Another "nested" list item + + 2. Another numbered list item + - A "nested" list item + - Another "nested" list item + + 3. Yet another numbered list item + - A "nested" list item + - Another "nested" list item + + --- + + 1. A numbered list item + \`\`\` const a = 2; \`\`\` - `, - assertions: (element) => { - const list = element.querySelector("ul"); - expect(list).toBeInTheDocument(); - - const listItems = list?.querySelectorAll("li"); - expect(listItems).toHaveLength(3); - - expect(listItems?.[0]).toHaveTextContent("A list item"); - - expect(listItems?.[1]).toHaveTextContent( - "Another list item withmultiple paragraphs." - ); - - expect(listItems?.[2]).toHaveTextContent("A task list item with"); - expect( - listItems?.[2]?.querySelector("input[type='checkbox']") - ).toBeChecked(); - expect(listItems?.[2]?.querySelector("blockquote")).toHaveTextContent( - "a quote and a code block" - ); - expect(listItems?.[2]?.querySelector("pre")).toHaveTextContent( - "const a = 2;" - ); - }, - }, - { - description: "numbered lists with arbitrary start indices", - content: dedent` - 1. A numbered list item - - A "nested" list item - - Another "nested" list item - - 2. Another numbered list item - - A "nested" list item - - Another "nested" list item - - 3. Yet another numbered list item - - A "nested" list item - - Another "nested" list item - - --- + + 2. Another numbered list item + + > A quote. + + 3. Yet another numbered list item + + A paragraph. + + --- + + 1. A numbered list item + 1. Another numbered list item + 1. Yet another numbered list item + `, + (root) => { + const listItems = root.querySelectorAll("li"); + expect(listItems).toHaveLength(15); + + // 1. A numbered list item + // - A "nested" list item + // - Another "nested" list item + // + // 2. Another numbered list item + // - A "nested" list item + // - Another "nested" list item + // + // 3. Yet another numbered list item + // - A "nested" list item + // - Another "nested" list item + const firstListFirstItem = listItems[0]?.parentElement; + expect(firstListFirstItem).not.toHaveAttribute("start"); + const firstListSecondItem = listItems[3]?.parentElement; + expect(firstListSecondItem).toHaveAttribute("start", "2"); + const firstListThirdItem = listItems[6]?.parentElement; + expect(firstListThirdItem).toHaveAttribute("start", "3"); + + // 1. A numbered list item + // + // \`\`\` + // const a = 2; + // \`\`\` + // + // 2. Another numbered list item + // + // > A quote. + // + // 3. Yet another numbered list item + // + // A paragraph. + const secondListFirstItem = listItems[9]?.parentElement; + expect(secondListFirstItem).not.toHaveAttribute("start"); + const secondListSecondItem = listItems[10]?.parentElement; + expect(secondListSecondItem).toHaveAttribute("start", "2"); + const secondListThirdItem = listItems[11]?.parentElement; + expect(secondListThirdItem).toHaveAttribute("start", "3"); + + // 1. A numbered list item + // 1. Another numbered list item + // 1. Yet another numbered list item + const thirdList = document.querySelector("ol:last-of-type"); + expect(listItems[12]?.parentElement).toBe(thirdList); + expect(listItems[13]?.parentElement).toBe(thirdList); + expect(listItems[14]?.parentElement).toBe(thirdList); + expect(thirdList).not.toHaveAttribute("start"); + } + ); + }); + + test("unordered lists", () => { + assert( + ` + - A list item + - Another list item + - Yet another list item + + * A list item + * Another list item + * Yet another list item + + + A list item + + Another list item + + Yet another list item + `, + (root) => { + const lists = root.querySelectorAll("ul"); + + lists.forEach((list) => { + const listItems = list.querySelectorAll("li"); + + expect(listItems).toHaveLength(3); + expect(listItems?.[0]?.textContent).toBe("A list item"); + expect(listItems?.[1]?.textContent).toBe("Another list item"); + expect(listItems?.[2]?.textContent).toBe("Yet another list item"); + }); + } + ); + }); + + test("task lists", () => { + assert( + ` + - [ ] A list item + - [x] Another list item + - [ ] Yet another list item + `, + (root) => { + const list = root.querySelector("ul"); + const listItems = list?.querySelectorAll("li"); + + expect(list).not.toBeNull(); + expect(listItems).toHaveLength(3); + expect(listItems?.[0]?.textContent).toBe(" A list item"); + expect( + listItems?.[0]?.querySelector("input[type='checkbox']") + ).not.toBeChecked(); + expect(listItems?.[1]?.textContent).toBe(" Another list item"); + expect( + listItems?.[1]?.querySelector("input[type='checkbox']") + ).toBeChecked(); + expect(listItems?.[2]?.textContent).toBe(" Yet another list item"); + expect( + listItems?.[2]?.querySelector("input[type='checkbox']") + ).not.toBeChecked(); + } + ); + }); + + test("mixed lists", () => { + assert( + ` + - A list item + 1. A nested list item + - Another nested list item + - [ ] A deeply nested list item + - Another list item + 1. A nested list item + 2. [x] Another nested list item + `, + (root) => { + const rootList = root.querySelector(":scope > ul"); + expect(rootList).toBeInTheDocument(); + + const rootListItems = root.querySelectorAll(":scope > ul > li"); + expect(rootListItems).toHaveLength(2); + + // - A list item + // 1. A nested list item + // - Another nested list item + // - [ ] A deeply nested list item + const firstNestedLists = root?.querySelectorAll( + ":scope > ul > li:nth-child(1) > :is(ul, ol)" + ); + expect(firstNestedLists).toHaveLength(2); + + // 1. A nested list item + const firstNestedFirstList = firstNestedLists?.[0]; + expect(firstNestedFirstList?.tagName).toBe("OL"); + expect(firstNestedFirstList?.childNodes).toHaveLength(1); + expect(firstNestedFirstList?.childNodes[0]?.textContent).toBe( + "A nested list item" + ); - 1. A numbered list item + // - Another nested list item + // - [ ] A deeply nested list item + const firstNestedSecondList = firstNestedLists?.[1]; + expect(firstNestedSecondList?.tagName).toBe("UL"); + expect(firstNestedSecondList?.childNodes).toHaveLength(1); + expect( + firstNestedSecondList?.childNodes[0]?.childNodes[0]?.textContent + ).toBe("Another nested list item"); + + // - [ ] A deeply nested list item + const firstNestedSecondListFirstNestedList = firstNestedSecondList + ?.childNodes[0]?.childNodes[1] as HTMLElement | null; + expect(firstNestedSecondListFirstNestedList?.textContent).toBe( + " A deeply nested list item" + ); + expect(firstNestedSecondListFirstNestedList?.tagName).toBe("UL"); + expect( + firstNestedSecondListFirstNestedList?.querySelector( + "input[type='checkbox']" + ) + ).not.toBeChecked(); + + // - Another list item + // 1. A nested list item + // 2. [x] Another nested list item + const secondNestedList = root?.querySelector( + ":scope > ul > li:nth-child(2) > :is(ul, ol)" + ); + expect(secondNestedList?.tagName).toBe("OL"); + expect(secondNestedList?.childNodes).toHaveLength(2); + expect(secondNestedList?.childNodes[0]?.textContent).toBe( + "A nested list item" + ); + expect(secondNestedList?.childNodes[1]?.textContent).toBe( + " Another nested list item" + ); + expect( + ( + secondNestedList?.childNodes[1] as HTMLElement | null + )?.querySelector("input[type='checkbox']") + ).toBeChecked(); + } + ); + }); + + test("loose lists", () => { + assert( + ` + - A list item + + - Another list item with + + multiple paragraphs. + + - [x] A task list item with + + > a quote and a code block + + \`\`\` + const a = 2; + \`\`\` + `, + (root) => { + const list = root.querySelector("ul"); + expect(list).toBeInTheDocument(); + + const listItems = list?.querySelectorAll("li"); + expect(listItems).toHaveLength(3); - \`\`\` - const a = 2; - \`\`\` + expect(listItems?.[0]?.textContent).toBe("A list item"); - 2. Another numbered list item + expect(listItems?.[1]?.innerHTML).toEqual( + "

    Another list item with

    multiple paragraphs.

    " + ); - > A quote. + expect(listItems?.[2]?.textContent).toContain( + " A task list item with" + ); + expect( + listItems?.[2]?.querySelector("input[type='checkbox']") + ).toBeChecked(); + expect(listItems?.[2]?.querySelector("blockquote")?.textContent).toBe( + "a quote and a code block" + ); + expect(listItems?.[2]?.querySelector("pre")?.textContent).toBe( + "const a = 2;" + ); + } + ); + }); + + test("blockquotes", () => { + assert( + ` + > A blockquote. + + > Another one which spans + > + > multiple paragraphs. + + > Yet another which + > + > > is nested. + `, + (root) => { + const blockquotes = root.querySelectorAll(":scope >blockquote"); + + expect(blockquotes).toHaveLength(3); + expect(blockquotes[0]?.textContent).toBe("A blockquote."); + + const blockquote1Paragraphs = blockquotes[1]?.querySelectorAll("p"); + expect(blockquote1Paragraphs).toHaveLength(2); + expect(blockquote1Paragraphs?.[0]?.textContent).toBe( + "Another one which spans" + ); + expect(blockquote1Paragraphs?.[1]?.textContent).toBe( + "multiple paragraphs." + ); - 3. Yet another numbered list item + expect(blockquotes[2]?.childNodes[0]?.textContent).toBe( + "Yet another which" + ); - A paragraph. + const blockquote2NestedBlockquote = blockquotes[2] + ?.childNodes[1] as HTMLElement | null; + expect(blockquote2NestedBlockquote?.tagName).toBe("BLOCKQUOTE"); + expect(blockquote2NestedBlockquote?.childNodes[0]?.textContent).toBe( + "is nested." + ); + } + ); + }); - --- + test("code blocks", () => { + assert( + ` + \`\`\` + p { + color: #000; + } + \`\`\` + + \`\`\`javascript + const a = 2; + \`\`\` + `, + (root) => { + const codeBlocks = root.querySelectorAll("pre"); - 1. A numbered list item - 1. Another numbered list item - 1. Yet another numbered list item - `, - assertions: (element) => { - const listItems = element.querySelectorAll("li"); - expect(listItems).toHaveLength(15); - - // 1. A numbered list item - // - A "nested" list item - // - Another "nested" list item - // - // 2. Another numbered list item - // - A "nested" list item - // - Another "nested" list item - // - // 3. Yet another numbered list item - // - A "nested" list item - // - Another "nested" list item - const firstListFirstItem = listItems[0]?.parentElement; - expect(firstListFirstItem).not.toHaveAttribute("start"); - const firstListSecondItem = listItems[3]?.parentElement; - expect(firstListSecondItem).toHaveAttribute("start", "2"); - const firstListThirdItem = listItems[6]?.parentElement; - expect(firstListThirdItem).toHaveAttribute("start", "3"); - - // 1. A numbered list item - // - // \`\`\` - // const a = 2; - // \`\`\` - // - // 2. Another numbered list item - // - // > A quote. - // - // 3. Yet another numbered list item - // - // A paragraph. - const secondListFirstItem = listItems[9]?.parentElement; - expect(secondListFirstItem).not.toHaveAttribute("start"); - const secondListSecondItem = listItems[10]?.parentElement; - expect(secondListSecondItem).toHaveAttribute("start", "2"); - const secondListThirdItem = listItems[11]?.parentElement; - expect(secondListThirdItem).toHaveAttribute("start", "3"); - - // 1. A numbered list item - // 1. Another numbered list item - // 1. Yet another numbered list item - const thirdList = document.querySelector("ol:last-of-type"); - expect(listItems[12]?.parentElement).toBe(thirdList); - expect(listItems[13]?.parentElement).toBe(thirdList); - expect(listItems[14]?.parentElement).toBe(thirdList); - expect(thirdList).not.toHaveAttribute("start"); - }, - }, - { - description: "blockquotes", - content: dedent` - > A blockquote. - - > Another one which spans - > - > multiple paragraphs. - - > Yet another which - > - > > is nested. - `, - assertions: (element) => { - const blockquotes = element.querySelectorAll(":scope >blockquote"); - - expect(blockquotes).toHaveLength(3); - expect(blockquotes[0]).toHaveTextContent("A blockquote."); - - const blockquote1Paragraphs = blockquotes[1]?.querySelectorAll("p"); - expect(blockquote1Paragraphs).toHaveLength(2); - expect(blockquote1Paragraphs?.[0]).toHaveTextContent( - "Another one which spans" - ); - expect(blockquote1Paragraphs?.[1]).toHaveTextContent( - "multiple paragraphs." - ); - - expect(blockquotes[2]?.childNodes[0]).toHaveTextContent( - "Yet another which" - ); - - const blockquote2NestedBlockquote = blockquotes[2] - ?.childNodes[1] as HTMLElement | null; - expect(blockquote2NestedBlockquote?.tagName).toBe("BLOCKQUOTE"); - expect(blockquote2NestedBlockquote?.childNodes[0]).toHaveTextContent( - "is nested." - ); - }, - }, - { - description: "code blocks", - content: dedent` - \`\`\` - p { - color: #000; + expect(codeBlocks).toHaveLength(2); + expect(codeBlocks[0]?.textContent).toBe("p {\n color: #000;\n}"); + expect(codeBlocks[1]?.textContent).toBe("const a = 2;"); } - \`\`\` + ); + }); + + test("images", () => { + assert( + ` + ![An image](https://www.liveblocks.io/favicon.svg) + `, + (root) => { + const images = root.querySelectorAll("img"); + + expect(images).toHaveLength(1); + expect(images[0]).toHaveAttribute( + "src", + "https://www.liveblocks.io/favicon.svg" + ); + } + ); + }); + + test("tables", () => { + assert( + ` + | A column heading | Another column heading | + |------------------|------------------------| + | A cell | Another cell | + | A cell | Another cell | + `, + (root) => { + const table = root.querySelector("table"); + expect(table).toBeInTheDocument(); + + const tableHeadings = table?.querySelectorAll("th"); + expect(tableHeadings).toHaveLength(2); + expect(tableHeadings?.[0]?.textContent).toBe("A column heading"); + expect(tableHeadings?.[1]?.textContent).toBe( + "Another column heading" + ); - \`\`\`javascript - const a = 2; - \`\`\` - `, - assertions: (element) => { - const codeBlocks = element.querySelectorAll("pre"); - expect(codeBlocks).toHaveLength(2); + const tableRows = table?.querySelectorAll("tbody tr"); + expect(tableRows).toHaveLength(2); - expect(codeBlocks[0]?.textContent).toBe("p {\n color: #000;\n}"); - expect(codeBlocks[1]?.textContent).toBe("const a = 2;"); - }, - }, - { - description: "images", - content: dedent` - ![An image](https://www.liveblocks.io/favicon.png) - `, - assertions: (element) => { - const images = element.querySelectorAll("img"); - - expect(images).toHaveLength(1); - expect(images[0]).toHaveAttribute( - "src", - "https://www.liveblocks.io/favicon.png" - ); - }, - }, - { - description: "tables", - content: dedent` - | A column heading | Another column heading | - |------------------|------------------------| - | A cell | Another cell | - | A cell | Another cell | - `, - assertions: (element) => { - const table = element.querySelector("table"); - expect(table).toBeInTheDocument(); - - const tableHeadings = table?.querySelectorAll("th"); - expect(tableHeadings).toHaveLength(2); - expect(tableHeadings?.[0]).toHaveTextContent("A column heading"); - expect(tableHeadings?.[1]).toHaveTextContent("Another column heading"); - - const tableRows = table?.querySelectorAll("tbody tr"); - expect(tableRows).toHaveLength(2); - - tableRows?.forEach((row) => { - const cells = row.querySelectorAll("td"); - - expect(cells).toHaveLength(2); - expect(cells?.[0]).toHaveTextContent("A cell"); - expect(cells?.[1]).toHaveTextContent("Another cell"); - }); - }, - }, - { - description: "horizontal rules", - content: dedent` - *** + tableRows?.forEach((row) => { + const cells = row.querySelectorAll("td"); - --- + expect(cells).toHaveLength(2); + expect(cells?.[0]?.textContent).toBe("A cell"); + expect(cells?.[1]?.textContent).toBe("Another cell"); + }); + } + ); - _____ - `, - assertions: (element) => { - const horizontalRules = element.querySelectorAll("hr"); + assert( + ` + | A column heading | Another column heading | + |------------------|------------------------| + | A cell | | + | | Another cell | + `, + (root) => { + const table = root.querySelector("table"); + expect(table).toBeInTheDocument(); + + const tableHeadings = table?.querySelectorAll("th"); + expect(tableHeadings).toHaveLength(2); + expect(tableHeadings?.[0]?.textContent).toBe("A column heading"); + expect(tableHeadings?.[1]?.textContent).toBe( + "Another column heading" + ); - expect(horizontalRules).toHaveLength(3); - }, - }, - { - description: "escaped characters", - content: dedent` - \\*Not italic\\* and \\[not a link\\]\\(https://liveblocks.io\). - `, - assertions: (element) => { - expect(element).toHaveTextContent( - "*Not italic* and [not a link](https://liveblocks.io)." - ); - }, - }, - { - description: "HTML entities", - content: dedent` - <p> & + const tableRows = table?.querySelectorAll("tbody tr"); + expect(tableRows).toHaveLength(2); - > " ' + const firstRowCells = tableRows?.[0]?.querySelectorAll("td"); + expect(firstRowCells).toHaveLength(2); + expect(firstRowCells?.[0]?.textContent).toBe("A cell"); + expect(firstRowCells?.[1]?.textContent).toBe(""); + + const secondRowCells = tableRows?.[1]?.querySelectorAll("td"); + expect(secondRowCells).toHaveLength(2); + expect(secondRowCells?.[0]?.textContent).toBe(""); + expect(secondRowCells?.[1]?.textContent).toBe("Another cell"); + } + ); + }); + + test("horizontal rules", () => { + assert( + ` + *** + + --- + + _____ + `, + (root) => { + expect(root.querySelectorAll("hr")).toHaveLength(3); + } + ); + }); + + test("escaped characters", () => { + assert( + ` + \\*Not italic\\* and \\[not a link\\]\\(https://liveblocks.io). + `, + (root) => { + expect(root.textContent).toBe( + "*Not italic* and [not a link](https://liveblocks.io)." + ); + } + ); + }); + + test("HTML entities", () => { + assert( + ` + <p> & paste© + + > " ' + + - © ™ + `, + (root) => { + expect(root.querySelector("p")?.textContent).toBe("

    & paste©"); + expect(root.querySelector("blockquote")?.textContent).toBe("\" '"); + expect(root.querySelector("li")?.textContent).toBe("© ™"); + } + ); + }); + + test("HTML elements as plain text", () => { + assert( + ` + The abbreviation for HyperText Markup Language is HTML. + + Press Ctrl + C to copy. + + This text is highlighted. + + E = mc2 + `, + (root) => { + const paragraphs = root.querySelectorAll("p"); + expect(paragraphs).toHaveLength(4); + + expect(paragraphs[0]?.textContent).toBe( + 'The abbreviation for HyperText Markup Language is HTML.' + ); + expect(paragraphs[1]?.textContent).toBe( + "Press Ctrl + C to copy." + ); + expect(paragraphs[2]?.textContent).toBe( + "This text is highlighted." + ); + expect(paragraphs[3]?.textContent).toBe("E = mc2"); + } + ); + }); + }); + + test("should handle different newlines", () => { + const content = + "# Heading 1\n\nA paragraph\r\r---\r\n\r\nAnother paragraph"; - - © ™ - `, - assertions: (element) => { - expect(element.querySelector("p")).toHaveTextContent("

    &"); - expect(element.querySelector("blockquote")).toHaveTextContent("\" '"); - expect(element.querySelector("li")).toHaveTextContent("© ™"); - }, - }, - { - description: "HTML elements (as plain text)", - content: dedent` - The abbreviation for HyperText Markup Language is HTML. - - Press Ctrl + C to copy. - - This is highlighted text. - - E = mc2 - `, - assertions: (element) => { - const paragraphs = element.querySelectorAll("p"); - expect(paragraphs).toHaveLength(4); - - expect(paragraphs[0]).toHaveTextContent( - "The abbreviation for HyperText Markup Language is HTML." - ); - expect(paragraphs[1]).toHaveTextContent("Press Ctrl + C to copy."); - expect(paragraphs[2]).toHaveTextContent("This is highlighted text."); - expect(paragraphs[3]).toHaveTextContent("E = mc2"); - }, - }, - ] satisfies { - description: string; - content: string; - assertions: (element: HTMLElement) => void; - }[])("should render $description", ({ content, assertions }) => { const { getByTestId } = render( ); + const { getByTestId: getByTestIdPartial } = render( + + ); + + const root = getByTestId("markdown"); + const rootPartial = getByTestIdPartial("markdown-partial"); + const roots = [root, rootPartial]; + + for (const root of roots) { + expect(root.querySelector("h1")?.textContent).toBe("Heading 1"); - assertions(getByTestId("markdown")); + const paragraphs = root.querySelectorAll("p"); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]?.textContent).toBe("A paragraph"); + expect(paragraphs[1]?.textContent).toBe("Another paragraph"); + + expect(root.querySelector("hr")).toBeInTheDocument(); + } }); - test("should rerender when the content changes", () => { - const { getByTestId, rerender } = render( - - ); + test("should only modify the last block when handling partial content", () => { + function assert(content: string, assertions: (root: HTMLElement) => void) { + const { getByTestId, unmount } = render( + + ); + + assertions(getByTestId("markdown")); + unmount(); + } - const element = getByTestId("markdown"); + assert( + ` + A paragraph without **bold - expect(element).toHaveTextContent("This is a [link](ht"); - expect(element.querySelector("a")).not.toBeInTheDocument(); + Another paragraph + `, + (root) => { + const paragraphs = root.querySelectorAll("p"); + expect(paragraphs).toHaveLength(2); - rerender( - + expect(paragraphs[0]?.textContent).toBe("A paragraph without **bold"); + expect(paragraphs[0]?.querySelector("strong")).not.toBeInTheDocument(); + expect(paragraphs[1]?.textContent).toBe("Another paragraph"); + } ); - expect(element).toHaveTextContent("This is a link."); - expect(element.querySelector("a")).toHaveAttribute( - "href", - "https://liveblocks.io" + assert( + ` + - A list item 👋 + - Another list item with **bold + `, + (root) => { + const listItems = root.querySelectorAll("li"); + expect(listItems).toHaveLength(2); + + expect(listItems[0]?.textContent).toBe("A list item 👋"); + expect(listItems[1]?.textContent).toBe("Another list item with bold"); + expect(listItems[1]?.querySelector("strong")?.textContent).toBe("bold"); + } ); }); - test.each([ - { - description: "paragraphs", - content: dedent`A paragraph.`, - components: { - Paragraph: ({ children }) =>

    {children}

    , - }, - assertions: (element) => { - const paragraph = element.querySelector("p"); - - expect(paragraph).toBeInTheDocument(); - expect(paragraph).toHaveTextContent("A paragraph."); - }, - }, - { - description: "headings", - content: dedent` - # Heading 1 + test("should rerender when enabling or disabling partial content", () => { + const { getByTestId, rerender } = render( + + ); + + const root = getByTestId("markdown"); + + expect(root.textContent).toBe("A [link"); + expect(root.querySelector("a")).not.toBeInTheDocument(); - ## Heading 2 + rerender(); - ### Heading 3 + expect(root.textContent).toBe("A link"); + expect(root.querySelector("a")?.textContent).toBe("link"); - #### Heading 4 + rerender(); - ##### Heading 5 + expect(root.textContent).toBe("A [link"); + expect(root.querySelector("a")).not.toBeInTheDocument(); + }); + + describe("should handle partial…", () => { + function assert(content: string, assertions: (root: HTMLElement) => void) { + const { getByTestId, unmount } = render( + + ); + + assertions(getByTestId("markdown")); + unmount(); + } - ###### Heading 6 + test("headings", () => { + assert( + ` + ### `, - components: { - Heading: ({ children, level }) => { - const Heading = `h${level}` as const; + (root) => { + expect(root.querySelector("h3")?.textContent).toBe(""); + } + ); - return {children}; - }, - }, - assertions: (element) => { - const headings = element.querySelectorAll("h1, h2, h3, h4, h5, h6"); - expect(headings).toHaveLength(6); - - headings.forEach((heading, index) => { - expect(heading).toHaveAttribute("data-heading", String(index + 1)); - expect(heading).toHaveTextContent(`Heading ${index + 1}`); - }); - }, - }, - { - description: "inline elements", - content: dedent` - This is **bold text**, _italic text_, **_bold and italic_**, ~~strikethrough~~, \`inline code\`, and **\`bold inline code\`**. + assert( + ` + No heading + = `, - components: { - Inline: ({ children, type }) => { - const Inline = type; + (root) => { + expect(root.querySelector("h1")).not.toBeInTheDocument(); + expect(root.textContent).toBe("No heading"); + } + ); - return {children}; - }, - }, - assertions: (element) => { - const strongs = element.querySelectorAll("strong"); - expect(strongs).toHaveLength(3); - expect(strongs[0]).toHaveTextContent("bold text"); - expect(strongs[0]).toHaveAttribute("data-inline", "strong"); - expect(strongs[1]).toHaveTextContent("bold and italic"); - expect(strongs[1]).toHaveAttribute("data-inline", "strong"); - expect(strongs[2]).toHaveTextContent("bold inline code"); - expect(strongs[2]).toHaveAttribute("data-inline", "strong"); - - const italics = element.querySelectorAll("em"); - expect(italics).toHaveLength(2); - expect(italics[0]).toHaveTextContent("italic text"); - expect(italics[0]).toHaveAttribute("data-inline", "em"); - expect(italics[1]).toHaveTextContent("bold and italic"); - expect(italics[1]).toHaveAttribute("data-inline", "em"); - - const strikethroughs = element.querySelectorAll("del"); - expect(strikethroughs).toHaveLength(1); - expect(strikethroughs[0]).toHaveTextContent("strikethrough"); - expect(strikethroughs[0]).toHaveAttribute("data-inline", "del"); - - const codes = element.querySelectorAll("code"); - expect(codes).toHaveLength(2); - expect(codes[0]).toHaveTextContent("inline code"); - expect(codes[0]).toHaveAttribute("data-inline", "code"); - expect(codes[1]).toHaveTextContent("bold inline code"); - expect(codes[1]).toHaveAttribute("data-inline", "code"); - }, - }, - { - description: "links", - content: dedent` - A [link](https://www.liveblocks.io), [another one](/docs "With a title"), - https://www.liveblocks.io, and . - `, - components: { - Link: ({ href, title, children }) => ( -
    - {children} - - ), - }, - assertions: (element) => { - const links = element.querySelectorAll("a"); - expect(links).toHaveLength(4); - - expect(links[0]).toHaveAttribute("data-link"); - expect(links[0]).toHaveAttribute("href", "https://www.liveblocks.io"); - expect(links[0]).toHaveTextContent("link"); - - expect(links[1]).toHaveAttribute("data-link"); - expect(links[1]).toHaveAttribute("href", "/docs"); - expect(links[1]).toHaveTextContent("another one"); - expect(links[1]).toHaveAttribute("title", "With a title"); - - expect(links[2]).toHaveAttribute("data-link"); - expect(links[2]).toHaveAttribute("href", "https://www.liveblocks.io"); - expect(links[2]).toHaveTextContent("https://www.liveblocks.io"); - - expect(links[3]).toHaveAttribute("data-link"); - expect(links[3]).toHaveAttribute("href", "https://www.liveblocks.io"); - expect(links[3]).toHaveTextContent("https://www.liveblocks.io"); - }, - }, - { - description: "lists", - content: dedent` - - A list item - 1. A list item - 2. Another list item - 3. Yet another list item - - Another list item - - [ ] A task list item - - [x] A completed task list item - - [x] Another completed task list item - - Yet another list item - 1. A list item - * Another list item - + [x] Yet another list item - `, - components: { - List: ({ items, type, start }) => { - const List = type === "ordered" ? "ol" : "ul"; - - return ( - - {items.map((item, index) => ( -
  • - {item.checked !== undefined && ( - <> - {" "} - - )} - {item.children} -
  • - ))} -
    - ); - }, - }, - assertions: (element) => { - const rootList = element.querySelector(":scope > ul"); - expect(rootList).toHaveAttribute("data-list"); - - const rootListItems = element.querySelectorAll(":scope > ul > li"); - expect(rootListItems).toHaveLength(3); - - expect(rootListItems[0]).toHaveTextContent("A list item"); - - const firstNestedList = rootListItems[0]?.querySelector("ol"); - expect(firstNestedList).toHaveAttribute("data-list"); - - const firstNestedListItems = firstNestedList?.querySelectorAll("li"); - expect(firstNestedListItems).toHaveLength(3); - - expect(firstNestedListItems?.[0]).toHaveTextContent("A list item"); - expect(firstNestedListItems?.[1]).toHaveTextContent( - "Another list item" - ); - expect(firstNestedListItems?.[2]).toHaveTextContent( - "Yet another list item" - ); - - const secondRootListItem = rootListItems[1]; - expect(secondRootListItem).toHaveTextContent("Another list item"); - - const secondNestedList = secondRootListItem?.querySelector("ul"); - expect(secondNestedList).toHaveAttribute("data-list"); - - const secondNestedListItems = secondNestedList?.querySelectorAll("li"); - expect(secondNestedListItems).toHaveLength(3); - - expect(secondNestedListItems?.[0]).toHaveTextContent( - "A task list item" - ); - expect( - secondNestedListItems?.[0]?.querySelector("input[type='checkbox']") - ).not.toBeChecked(); - expect(secondNestedListItems?.[1]).toHaveTextContent( - "A completed task list item" - ); - expect( - secondNestedListItems?.[1]?.querySelector("input[type='checkbox']") - ).toBeChecked(); - expect(secondNestedListItems?.[2]).toHaveTextContent( - "Another completed task list item" - ); - expect( - secondNestedListItems?.[2]?.querySelector("input[type='checkbox']") - ).toBeChecked(); - - const thirdRootListItem = rootListItems[2]; - expect(thirdRootListItem).toHaveTextContent("Yet another list item"); - - const thirdNestedLists = thirdRootListItem?.querySelectorAll("ol, ul"); - expect(thirdNestedLists).toHaveLength(3); - - expect(thirdNestedLists?.[0]).toHaveAttribute("data-list", "ordered"); - expect(thirdNestedLists?.[0]?.querySelector("li")).toHaveTextContent( - "A list item" - ); - - expect(thirdNestedLists?.[1]).toHaveAttribute("data-list", "unordered"); - expect(thirdNestedLists?.[1]?.querySelector("li")).toHaveTextContent( - "Another list item" - ); - - expect(thirdNestedLists?.[2]).toHaveAttribute("data-list", "unordered"); - expect(thirdNestedLists?.[2]?.querySelector("li")).toHaveTextContent( - "Yet another list item" - ); - expect( - thirdNestedLists?.[2]?.querySelector("input[type='checkbox']") - ).toBeChecked(); - }, - }, - { - description: "blockquotes", - content: dedent` - > A blockquote. - - > Another one which spans - > - > multiple paragraphs. - - > Yet another which - > - > > is nested. - `, - components: { - Blockquote: ({ children }) => ( -
    {children}
    - ), - }, - assertions: (element) => { - const blockquotes = element.querySelectorAll("blockquote"); - expect(blockquotes).toHaveLength(4); - - expect(blockquotes[0]).toHaveTextContent("A blockquote."); - expect(blockquotes[0]).toHaveAttribute("data-blockquote"); - expect(blockquotes[1]).toHaveTextContent( - "Another one which spansmultiple paragraphs." - ); - expect(blockquotes[1]).toHaveAttribute("data-blockquote"); - expect(blockquotes[2]).toHaveTextContent("Yet another whichis nested."); - expect(blockquotes[2]).toHaveAttribute("data-blockquote"); - expect(blockquotes[3]).toHaveTextContent("is nested."); - expect(blockquotes[3]).toHaveAttribute("data-blockquote"); - }, - }, - { - description: "code blocks", - content: dedent` - \`\`\` - p { - color: #000; + assert( + ` + No heading + == + `, + (root) => { + expect(root.querySelector("h1")).not.toBeInTheDocument(); + expect(root.textContent).toBe("No heading"); } - \`\`\` + ); - \`\`\`javascript - const a = 2; - \`\`\` + assert( + ` + Heading 1 + === `, - components: { - CodeBlock: ({ code, language }) => ( -
    -            {code}
    -          
    - ), - }, - assertions: (element) => { - const codeBlocks = element.querySelectorAll("pre"); - expect(codeBlocks).toHaveLength(2); - - expect(codeBlocks[0]).toHaveTextContent("p { color: #000; }"); - expect(codeBlocks[0]).toHaveAttribute("data-code-block"); - expect(codeBlocks[1]).toHaveTextContent("const a = 2;"); - expect(codeBlocks[1]).toHaveAttribute("data-code-block"); - expect(codeBlocks[1]?.querySelector("code")).toHaveAttribute( - "data-language", - "javascript" - ); - }, - }, - { - description: "images", - content: dedent` - ![An image](https://www.liveblocks.io/favicon.png) - `, - components: { - Image: ({ src, alt }) => {alt}, - }, - assertions: (element) => { - const image = element.querySelector("img"); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute( - "src", - "https://www.liveblocks.io/favicon.png" - ); - expect(image).toHaveAttribute("alt", "An image"); - }, - }, - { - description: "tables", - content: dedent` - | Feature | Example | Notes | - | ------------- | ------------------------------------ | ------------------------- | - | Link | [Liveblocks](https://liveblocks.io/) | External link | - | Inline code | \`const a = 2;\` | Code inside table | - | Bold text | **Important** | Styling test | - | Italic text | _Emphasis_ | Test italic inside tables | - | Strikethrough | ~~Deprecated~~ | Show removal | - `, - components: { - Table: ({ headings, rows }) => ( - - - - {headings.map((heading, index) => ( - - ))} - - - - {rows.map((row, index) => ( - - {row.map((cell, index) => ( - - ))} - - ))} - -
    {heading.children}
    {cell.children}
    - ), - }, - assertions: (element) => { - const table = element.querySelector("table"); - - expect(table).toBeInTheDocument(); - expect(table).toHaveAttribute("data-table"); - - const headings = table?.querySelectorAll("th"); - expect(headings).toHaveLength(3); - - expect(headings?.[0]).toHaveTextContent("Feature"); - expect(headings?.[1]).toHaveTextContent("Example"); - expect(headings?.[2]).toHaveTextContent("Notes"); - - const rows = table?.querySelectorAll("tbody tr"); - expect(rows).toHaveLength(5); - - const firstRowCells = rows?.[0]?.querySelectorAll("td"); - expect(firstRowCells).toHaveLength(3); - - expect(firstRowCells?.[0]).toHaveTextContent("Link"); - expect(firstRowCells?.[1]).toHaveTextContent("Liveblocks"); - expect(firstRowCells?.[1]?.querySelector("a")).toHaveAttribute( - "href", - "https://liveblocks.io/" - ); - expect(firstRowCells?.[2]).toHaveTextContent("External link"); - - const secondRowCells = rows?.[1]?.querySelectorAll("td"); - expect(secondRowCells).toHaveLength(3); - - expect(secondRowCells?.[0]).toHaveTextContent("Inline code"); - expect(secondRowCells?.[1]).toHaveTextContent("const a = 2;"); - expect(secondRowCells?.[1]?.querySelector("code")).toHaveTextContent( - "const a = 2;" - ); - expect(secondRowCells?.[2]).toHaveTextContent("Code inside table"); - - const thirdRowCells = rows?.[2]?.querySelectorAll("td"); - expect(thirdRowCells).toHaveLength(3); - - expect(thirdRowCells?.[0]).toHaveTextContent("Bold text"); - expect(thirdRowCells?.[1]).toHaveTextContent("Important"); - expect(thirdRowCells?.[1]?.querySelector("strong")).toHaveTextContent( - "Important" - ); - expect(thirdRowCells?.[2]).toHaveTextContent("Styling test"); - - const fourthRowCells = rows?.[3]?.querySelectorAll("td"); - expect(fourthRowCells).toHaveLength(3); - - expect(fourthRowCells?.[0]).toHaveTextContent("Italic text"); - expect(fourthRowCells?.[1]).toHaveTextContent("Emphasis"); - expect(fourthRowCells?.[1]?.querySelector("em")).toHaveTextContent( - "Emphasis" - ); - expect(fourthRowCells?.[2]).toHaveTextContent( - "Test italic inside tables" - ); - - const fifthRowCells = rows?.[4]?.querySelectorAll("td"); - expect(fifthRowCells).toHaveLength(3); - - expect(fifthRowCells?.[0]).toHaveTextContent("Strikethrough"); - expect(fifthRowCells?.[1]).toHaveTextContent("Deprecated"); - expect(fifthRowCells?.[1]?.querySelector("del")).toHaveTextContent( - "Deprecated" - ); - expect(fifthRowCells?.[2]).toHaveTextContent("Show removal"); - }, - }, - { - description: "separators", - content: dedent` - *** + (root) => { + expect(root.querySelector("h1")?.textContent).toBe("Heading 1"); + } + ); - --- + assert( + ` + No heading + - + `, + (root) => { + expect(root.querySelector("h2")).not.toBeInTheDocument(); + expect(root.textContent).toBe("No heading"); + } + ); - _____ - `, - components: { - Separator: () =>
    , - }, - assertions: (element) => { - const separators = element.querySelectorAll("hr"); - expect(separators).toHaveLength(3); - - separators.forEach((separator) => { - expect(separator).toHaveAttribute("data-separator"); - }); - }, - }, - ] satisfies { - description: string; - content: string; - components: Partial; - assertions: (element: HTMLElement) => void; - }[])( - "should support overriding $description", - ({ content, components, assertions }) => { - const { getByTestId } = render( - + assert( + ` + No heading + -- + `, + (root) => { + expect(root.querySelector("h2")).not.toBeInTheDocument(); + expect(root.textContent).toBe("No heading"); + } ); - assertions(getByTestId("markdown")); - } - ); + assert( + ` + Heading 2 + --- + `, + (root) => { + expect(root.querySelector("h2")?.textContent).toBe("Heading 2"); + } + ); + }); + + test("bold text", () => { + assert( + ` + Not ** + `, + (root) => { + expect(root.textContent).toBe("Not "); + } + ); + + assert( + ` + Not __ + `, + (root) => { + expect(root.textContent).toBe("Not "); + } + ); + + assert( + ` + **Bold text + `, + (root) => { + expect(root.querySelector("strong")?.textContent).toBe("Bold text"); + } + ); + + assert( + ` + __Bold text + `, + (root) => { + expect(root.querySelector("strong")?.textContent).toBe("Bold text"); + } + ); + }); + + test("italic text", () => { + assert( + ` + Not * + `, + (root) => { + expect(root.textContent).toBe("Not "); + } + ); + + assert( + ` + Not _ + `, + (root) => { + expect(root.textContent).toBe("Not "); + } + ); + + assert( + ` + *Italic text + `, + (root) => { + expect(root.querySelector("em")?.textContent).toBe("Italic text"); + } + ); + + assert( + ` + _Italic text + `, + (root) => { + expect(root.querySelector("em")?.textContent).toBe("Italic text"); + } + ); + }); + + test("strikethrough text", () => { + assert( + ` + Not ~ + `, + (root) => { + expect(root.textContent).toBe("Not "); + } + ); + + assert( + ` + Not ~~ + `, + (root) => { + expect(root.textContent).toBe("Not "); + } + ); + + assert( + ` + ~Strikethrough text + `, + (root) => { + expect(root.querySelector("del")?.textContent).toBe( + "Strikethrough text" + ); + } + ); + + assert( + ` + ~~Strikethrough text + `, + (root) => { + expect(root.querySelector("del")?.textContent).toBe( + "Strikethrough text" + ); + } + ); + }); + + test("inline code", () => { + assert( + ` + Not inline \` + `, + (root) => { + expect(root?.textContent).toBe("Not inline "); + } + ); + + assert( + ` + Inline \`code + `, + (root) => { + expect(root.querySelector("code")?.textContent).toBe("code"); + } + ); + }); + + test("links", () => { + assert( + ` + A [ + `, + (root) => { + const link = root.querySelector("a"); + + expect(link?.textContent).toBe(""); + expect(link).toHaveAttribute("href", "#"); + } + ); + + assert( + ` + A [li + `, + (root) => { + const link = root.querySelector("a"); + + expect(link?.textContent).toBe("li"); + expect(link).toHaveAttribute("href", "#"); + } + ); + + assert( + ` + A [link] + `, + (root) => { + const link = root.querySelector("a"); + + expect(link?.textContent).toBe("link"); + expect(link).toHaveAttribute("href", "#"); + } + ); + + assert( + ` + Not a [link], + `, + (root) => { + const link = root.querySelector("a"); + + expect(link).not.toBeInTheDocument(); + expect(root.textContent).toBe("Not a [link],"); + } + ); + + assert( + ` + A [link](http + `, + (root) => { + const link = root.querySelector("a"); + + expect(link?.textContent).toBe("link"); + expect(link).toHaveAttribute("href", "#"); + } + ); + + assert( + ` + A [link](https://www.liveblocks.io + `, + (root) => { + const link = root.querySelector("a"); + + expect(link?.textContent).toBe("link"); + expect(link).toHaveAttribute("href", "https://www.liveblocks.io"); + } + ); + + // Links can have titles. + assert( + ` + A [link](https://www.liveblocks.io "Liveblocks + `, + (root) => { + const link = root.querySelector("a"); + + expect(link?.textContent).toBe("link"); + expect(link).toHaveAttribute("href", "#"); + } + ); + + // Footnotes aren't links. + assert( + ` + Not a [^ + `, + (root) => { + expect(root.querySelector("a")).not.toBeInTheDocument(); + } + ); + + assert( + ` + Not a [^1 + `, + (root) => { + expect(root.querySelector("a")).not.toBeInTheDocument(); + } + ); + }); + + test("nested inline elements", () => { + assert( + ` + **Bold _italic \`code + `, + (root) => { + const strong = root.querySelector("strong"); + const em = strong?.querySelector("em"); + const code = em?.querySelector("code"); + + expect(strong?.textContent).toBe("Bold italic code"); + expect(em?.textContent).toBe("italic code"); + expect(code?.textContent).toBe("code"); + } + ); + + assert( + ` + **Bold _italic_ \`code + `, + (root) => { + const strong = root.querySelector("strong"); + const em = strong?.querySelector("em"); + const code = strong?.querySelector("code"); + + expect(strong?.textContent).toBe("Bold italic code"); + expect(em?.textContent).toBe("italic"); + expect(code?.textContent).toBe("code"); + } + ); + + assert( + ` + A **bold [link + `, + (root) => { + const strong = root.querySelector("strong"); + const link = strong?.querySelector("a"); + + expect(strong?.textContent).toBe("bold link"); + expect(link?.textContent).toBe("link"); + expect(link).toHaveAttribute("href", "#"); + } + ); + + assert( + ` + A [link with \`code + `, + (root) => { + const link = root.querySelector("a"); + const code = link?.querySelector("code"); + + expect(link?.textContent).toBe("link with code"); + expect(link).toHaveAttribute("href", "#"); + expect(code?.textContent).toBe("code"); + } + ); + + assert( + ` + A [link with **bold \`code\` + `, + (root) => { + const link = root.querySelector("a"); + const strong = link?.querySelector("strong"); + const code = strong?.querySelector("code"); + + expect(link?.textContent).toBe("link with bold code"); + expect(link).toHaveAttribute("href", "#"); + expect(strong?.textContent).toBe("bold code"); + expect(code?.textContent).toBe("code"); + } + ); + + assert( + ` + Inline \`code but not **bold + `, + (root) => { + const code = root.querySelector("code"); + const strong = code?.querySelector("strong"); + + expect(code?.textContent).toBe("code but not **bold"); + expect(strong).not.toBeInTheDocument(); + } + ); + + assert( + ` + Inline \`code but not _italic + `, + (root) => { + const code = root.querySelector("code"); + const em = code?.querySelector("em"); + + expect(code?.textContent).toBe("code but not _italic"); + expect(em).not.toBeInTheDocument(); + } + ); + + assert( + ` + Inline \`code but not ~~strikethrough + `, + (root) => { + const code = root.querySelector("code"); + const del = code?.querySelector("del"); + + expect(code?.textContent).toBe("code but not ~~strikethrough"); + expect(del).not.toBeInTheDocument(); + } + ); + + assert( + ` + Inline \`code but not a [link + `, + (root) => { + const code = root.querySelector("code"); + const link = code?.querySelector("a"); + + expect(code?.textContent).toBe("code but not a [link"); + expect(link).not.toBeInTheDocument(); + } + ); + + assert( + ` + ***Three + `, + (root) => { + const em = root.querySelector("em"); + const strong = em?.querySelector("strong"); + + expect(root.textContent).toBe("Three"); + expect(em?.textContent).toBe("Three"); + expect(strong?.textContent).toBe("Three"); + } + ); + + assert( + ` + ***Three*** + `, + (root) => { + const em = root.querySelector("em"); + const strong = em?.querySelector("strong"); + + expect(root.textContent).toBe("Three"); + expect(em?.textContent).toBe("Three"); + expect(strong?.textContent).toBe("Three"); + } + ); + + assert( + ` + ***Three***, + `, + (root) => { + const em = root.querySelector("em"); + const strong = em?.querySelector("strong"); + + expect(root.textContent).toBe("Three,"); + expect(em?.textContent).toBe("Three"); + expect(strong?.textContent).toBe("Three"); + } + ); + + assert( + ` + ***Three***, ****four + `, + (root) => { + const em = root.querySelector("p > em"); + const strong = root.querySelector("p > strong"); + + expect(root.textContent).toBe("Three, four"); + expect(em?.textContent).toBe("Three"); + expect(strong?.textContent).toBe("four"); + expect(strong?.querySelector("strong")?.textContent).toBe("four"); + } + ); + + assert( + ` + ****Four + `, + (root) => { + const strongs = root.querySelectorAll("strong"); + + expect(strongs).toHaveLength(2); + expect(strongs[0]?.textContent).toBe("Four"); + expect(strongs[1]?.textContent).toBe("Four"); + expect(root.textContent).toBe("Four"); + } + ); + + assert( + ` + *****Five + `, + (root) => { + const em = root.querySelector("em"); + const strongs = em?.querySelectorAll("strong"); + + expect(root.textContent).toBe("Five"); + expect(em?.textContent).toBe("Five"); + expect(strongs).toHaveLength(2); + expect(strongs?.[0]?.textContent).toBe("Five"); + expect(strongs?.[1]?.textContent).toBe("Five"); + } + ); + + assert( + ` + ___Three + `, + (root) => { + const em = root.querySelector("em"); + const strong = em?.querySelector("strong"); + + expect(root.textContent).toBe("Three"); + expect(em?.textContent).toBe("Three"); + expect(strong?.textContent).toBe("Three"); + } + ); + + assert( + ` + ___Three___ + `, + (root) => { + const em = root.querySelector("em"); + const strong = em?.querySelector("strong"); + + expect(root.textContent).toBe("Three"); + expect(em?.textContent).toBe("Three"); + expect(strong?.textContent).toBe("Three"); + } + ); + + assert( + ` + ___Three___, + `, + (root) => { + const em = root.querySelector("em"); + const strong = em?.querySelector("strong"); + + expect(root.textContent).toBe("Three,"); + expect(em?.textContent).toBe("Three"); + expect(strong?.textContent).toBe("Three"); + } + ); + + assert( + ` + ___Three___, ____four + `, + (root) => { + const em = root.querySelector("p > em"); + const strong = root.querySelector("p > strong"); + + expect(root.textContent).toBe("Three, four"); + expect(em?.textContent).toBe("Three"); + expect(strong?.textContent).toBe("four"); + expect(strong?.querySelector("strong")?.textContent).toBe("four"); + } + ); + + assert( + ` + ____Four + `, + (root) => { + const strongs = root.querySelectorAll("strong"); + + expect(strongs).toHaveLength(2); + expect(strongs[0]?.textContent).toBe("Four"); + expect(strongs[1]?.textContent).toBe("Four"); + expect(root.textContent).toBe("Four"); + } + ); + + assert( + ` + _____Five + `, + (root) => { + const em = root.querySelector("em"); + const strongs = em?.querySelectorAll("strong"); + + expect(root.textContent).toBe("Five"); + expect(em?.textContent).toBe("Five"); + expect(strongs).toHaveLength(2); + expect(strongs?.[0]?.textContent).toBe("Five"); + expect(strongs?.[1]?.textContent).toBe("Five"); + } + ); + }); + + test("lists", () => { + assert( + ` + - + `, + (root) => { + expect(root?.textContent).toBe(""); + } + ); + + assert( + ` + - A list item + `, + (root) => { + expect(root.querySelector("li")?.textContent).toBe("A list item"); + } + ); + + assert( + ` + 1 + `, + (root) => { + expect(root?.textContent).toBe(""); + } + ); + + assert( + ` + 1. A list item + `, + (root) => { + expect(root.querySelector("li")?.textContent).toBe("A list item"); + } + ); + + assert( + ` + - [ + `, + (root) => { + expect( + root.querySelector("li")?.querySelector("input[type='checkbox']") + ).not.toBeChecked(); + } + ); + + assert( + ` + - [ ] + `, + (root) => { + expect( + root.querySelector("li")?.querySelector("input[type='checkbox']") + ).not.toBeChecked(); + } + ); + + assert( + ` + - [x + `, + (root) => { + expect( + root.querySelector("li")?.querySelector("input[type='checkbox']") + ).toBeChecked(); + } + ); + + assert( + ` + - [x] + `, + (root) => { + expect( + root.querySelector("li")?.querySelector("input[type='checkbox']") + ).toBeChecked(); + } + ); + }); + + test("images", () => { + assert( + ` + Not an image: ! + `, + (root) => { + expect(root.querySelector("img")).not.toBeInTheDocument(); + expect(root?.textContent).toBe("Not an image: "); + } + ); + + assert( + ` + Not an image: ![ + `, + (root) => { + expect(root.querySelector("img")).not.toBeInTheDocument(); + expect(root?.textContent).toBe("Not an image: "); + } + ); + + assert( + ` + Not an image: ![image + `, + (root) => { + expect(root.querySelector("img")).not.toBeInTheDocument(); + expect(root?.textContent).toBe("Not an image: "); + } + ); + + assert( + ` + Not an image: ![image](https://www.liveblocks.io/favicon.svg + `, + (root) => { + expect(root.querySelector("img")).not.toBeInTheDocument(); + expect(root?.textContent).toBe("Not an image: "); + } + ); + + assert( + ` + Not an image: ![](https://www.liveblocks.io/favicon.svg + `, + (root) => { + expect(root.querySelector("img")).not.toBeInTheDocument(); + expect(root?.textContent).toBe("Not an image: "); + } + ); + + assert( + ` + An image: ![image](https://www.liveblocks.io/favicon.svg) + `, + (root) => { + const image = root.querySelector("img"); + + expect(image).toHaveAttribute( + "src", + "https://www.liveblocks.io/favicon.svg" + ); + expect(image).toHaveAttribute("alt", "image"); + expect(root?.textContent).toBe("An image: "); + } + ); + + assert( + ` + An image: ![](https://www.liveblocks.io/favicon.svg) + `, + (root) => { + const image = root.querySelector("img"); + + expect(image).toHaveAttribute( + "src", + "https://www.liveblocks.io/favicon.svg" + ); + expect(image).toHaveAttribute("alt", ""); + expect(root?.textContent).toBe("An image: "); + } + ); + }); + + test("code blocks", () => { + assert( + ` + \`\` + `, + (root) => { + expect(root?.textContent).toBe(""); + } + ); + + assert( + ` + \`\`\` + `, + (root) => { + expect(root?.textContent).toBe(""); + } + ); + + assert( + ` + \`\`\`css + `, + (root) => { + const codeBlock = root.querySelector("pre"); + + expect(codeBlock).toBeInTheDocument(); + expect(codeBlock).toHaveAttribute("data-language", "css"); + expect(codeBlock?.textContent).toBe(" "); + } + ); + + assert( + ` + \`\`\`css + p { + color: #000; + `, + (root) => { + const codeBlock = root.querySelector("pre"); + + expect(codeBlock).toBeInTheDocument(); + expect(codeBlock?.querySelector("code")?.innerHTML).toBe( + "p {\n color: #000;" + ); + expect(codeBlock).toHaveAttribute("data-language", "css"); + } + ); + + // Code blocks shouldn't be completed/changed. + // In this case, the ending looks like a partial link. + assert( + ` + \`\`\` + const [count, setCount + `, + (root) => { + const codeBlock = root.querySelector("pre"); + + expect(codeBlock).toBeInTheDocument(); + expect(codeBlock?.querySelector("code")?.innerHTML).toBe( + "const [count, setCount" + ); + expect(codeBlock).toHaveAttribute("data-language", ""); + } + ); + + assert( + ` + \`\`\`css + p { + color: #000; + } + \`\` + `, + (root) => { + const codeBlock = root.querySelector("pre"); + + expect(codeBlock).toBeInTheDocument(); + expect(codeBlock?.querySelector("code")?.innerHTML).toBe( + "p {\n color: #000;\n}" + ); + expect(codeBlock).toHaveAttribute("data-language", "css"); + } + ); + + assert( + ` + \`\`\`css + p { + color: #000; + } + \`\`\` + `, + (root) => { + const codeBlock = root.querySelector("pre"); + + expect(codeBlock).toBeInTheDocument(); + expect(codeBlock?.querySelector("code")?.innerHTML).toBe( + "p {\n color: #000;\n}" + ); + expect(codeBlock).toHaveAttribute("data-language", "css"); + } + ); + }); + + test("tables", () => { + assert( + ` + Not a table | + `, + (root) => { + expect(root?.textContent).toBe("Not a table |"); + } + ); + assert( + ` + | A column heading + `, + (root) => { + const table = root.querySelector("table"); + expect(table).toBeInTheDocument(); + + const tableHeadings = table?.querySelectorAll("th"); + expect(tableHeadings).toHaveLength(1); + expect(tableHeadings?.[0]?.textContent).toBe("A column heading"); + } + ); + + assert( + ` + | A column heading | Another column + `, + (root) => { + const table = root.querySelector("table"); + expect(table).toBeInTheDocument(); + + const tableHeadings = table?.querySelectorAll("th"); + expect(tableHeadings).toHaveLength(2); + expect(tableHeadings?.[0]?.textContent).toBe("A column heading"); + expect(tableHeadings?.[1]?.textContent).toBe("Another column"); + } + ); + + assert( + ` + | A column heading | Another column heading | + |------------------| + `, + (root) => { + const table = root.querySelector("table"); + expect(table).toBeInTheDocument(); + + const tableHeadings = table?.querySelectorAll("th"); + expect(tableHeadings).toHaveLength(2); + expect(tableHeadings?.[0]?.textContent).toBe("A column heading"); + expect(tableHeadings?.[1]?.textContent).toBe( + "Another column heading" + ); + } + ); + + assert( + ` + A paragraph + + | A column heading | Another column heading | + |------------------| + `, + (root) => { + const table = root.querySelector("table"); + expect(table).toBeInTheDocument(); + + const tableHeadings = table?.querySelectorAll("th"); + expect(tableHeadings).toHaveLength(2); + expect(tableHeadings?.[0]?.textContent).toBe("A column heading"); + expect(tableHeadings?.[1]?.textContent).toBe( + "Another column heading" + ); + } + ); + }); + + test("horizontal rules", () => { + assert( + ` + A paragraph + + - + `, + (root) => { + expect(root?.textContent).toBe("A paragraph"); + } + ); + + assert( + ` + A paragraph + + _ + `, + (root) => { + expect(root?.textContent).toBe("A paragraph"); + } + ); + + assert( + ` + A paragraph + + -- + `, + (root) => { + expect(root?.textContent).toBe("A paragraph"); + } + ); + + assert( + ` + A paragraph + + __ + `, + (root) => { + expect(root?.textContent).toBe("A paragraph"); + } + ); + + assert( + ` + A paragraph + + --- + + Another paragraph + `, + (root) => { + expect(root.querySelector("hr")).toBeInTheDocument(); + } + ); + + assert( + ` + A paragraph + + ___ + + Another paragraph + `, + (root) => { + expect(root.querySelector("hr")).toBeInTheDocument(); + } + ); + }); + + test("escaped characters", () => { + assert( + ` + A paragraph \\ + `, + (root) => { + expect(root?.textContent).toBe("A paragraph "); + } + ); + + assert( + ` + A paragraph \\~ + `, + (root) => { + expect(root?.textContent).toBe("A paragraph ~"); + } + ); + }); + + test("HTML elements", () => { + assert( + ` + The abbreviation for HyperText Markup Language is < + `, + (root) => { + expect(root?.textContent).toBe( + "The abbreviation for HyperText Markup Language is " + ); + } + ); + + assert( + ` + The abbreviation for HyperText Markup Language is HT + `, + (root) => { + expect(root?.textContent).toBe( + 'The abbreviation for HyperText Markup Language is HT' + ); + } + ); + + assert( + ` + The abbreviation for HyperText Markup Language is HTML. + `, + (root) => { + expect(root?.textContent).toBe( + 'The abbreviation for HyperText Markup Language is HTML.' + ); + } + ); + }); + + test("emojis", () => { + assert( + ` + An emoji 👋 + `, + (root) => { + expect(root?.textContent).toBe("An emoji "); + } + ); + + assert( + ` + A skin tone sequence emoji 👋🏽 + `, + (root) => { + expect(root?.textContent).toBe("A skin tone sequence emoji "); + } + ); + + assert( + ` + A partial sequence emoji 👋\u200D + `, + (root) => { + expect(root?.textContent).toBe("A partial sequence emoji "); + } + ); + + assert( + ` + An umbrella as a pictographic ☂ + `, + (root) => { + expect(root?.textContent).toBe("An umbrella as a pictographic ☂"); + } + ); + + assert( + ` + An umbrella as an emoji ☂️ + `, + (root) => { + expect(root?.textContent).toBe("An umbrella as an emoji "); + } + ); + + assert( + ` + A partial flag emoji 🇺 + `, + (root) => { + expect(root?.textContent).toBe("A partial flag emoji "); + } + ); + + assert( + ` + A flag emoji 🇺🇳 + `, + (root) => { + expect(root?.textContent).toBe("A flag emoji "); + } + ); + + assert( + ` + A keycap sequence emoji 1️⃣ + `, + (root) => { + expect(root?.textContent).toBe("A keycap sequence emoji "); + } + ); + + assert( + ` + An emoji 👋 and some text + `, + (root) => { + expect(root?.textContent).toBe("An emoji 👋 and some text"); + } + ); + }); + }); + + describe("should support custom…", () => { + function assert( + content: string, + components: Partial, + assertions: (root: HTMLElement) => void + ) { + const { getByTestId, unmount } = render( + + ); + + assertions(getByTestId("markdown")); + unmount(); + } + + test("paragraphs", () => { + assert( + ` + A paragraph + `, + { + Paragraph: ({ children }) =>

    {children}

    , + }, + (root) => { + const paragraph = root.querySelector("p"); + + expect(paragraph?.textContent).toBe("A paragraph"); + expect(paragraph).toHaveAttribute("data-paragraph"); + } + ); + }); + + test("headings", () => { + assert( + ` + # Heading 1 + + ## Heading 2 + + ### Heading 3 + + #### Heading 4 + + ##### Heading 5 + + ###### Heading 6 + `, + { + Heading: ({ children, level }) => { + const Heading = `h${level}` as const; + + return {children}; + }, + }, + (root) => { + const headings = root.querySelectorAll("h1, h2, h3, h4, h5, h6"); + expect(headings).toHaveLength(6); + + headings.forEach((heading, index) => { + expect(heading).toHaveAttribute("data-heading", String(index + 1)); + expect(heading.textContent).toBe(`Heading ${index + 1}`); + }); + } + ); + }); + + test("inline elements", () => { + assert( + ` + **Bold text**, _italic text_, **_bold and italic_**, ~~strikethrough~~, \`inline code\`, and **\`bold inline code\`**. + `, + { + Inline: ({ children, type }) => { + const Inline = type; + + return {children}; + }, + }, + (root) => { + const strongs = root.querySelectorAll("strong"); + expect(strongs).toHaveLength(3); + expect(strongs[0]?.textContent).toBe("Bold text"); + expect(strongs[0]).toHaveAttribute("data-inline", "strong"); + expect(strongs[1]?.textContent).toBe("bold and italic"); + expect(strongs[1]).toHaveAttribute("data-inline", "strong"); + expect(strongs[2]?.textContent).toBe("bold inline code"); + expect(strongs[2]).toHaveAttribute("data-inline", "strong"); + + const italics = root.querySelectorAll("em"); + expect(italics).toHaveLength(2); + expect(italics[0]?.textContent).toBe("italic text"); + expect(italics[0]).toHaveAttribute("data-inline", "em"); + expect(italics[1]?.textContent).toBe("bold and italic"); + expect(italics[1]).toHaveAttribute("data-inline", "em"); + + const strikethroughs = root.querySelectorAll("del"); + expect(strikethroughs).toHaveLength(1); + expect(strikethroughs[0]?.textContent).toBe("strikethrough"); + expect(strikethroughs[0]).toHaveAttribute("data-inline", "del"); + + const codes = root.querySelectorAll("code"); + expect(codes).toHaveLength(2); + expect(codes[0]?.textContent).toBe("inline code"); + expect(codes[0]).toHaveAttribute("data-inline", "code"); + expect(codes[1]?.textContent).toBe("bold inline code"); + expect(codes[1]).toHaveAttribute("data-inline", "code"); + } + ); + }); + + test("links", () => { + assert( + ` + A [link](https://www.liveblocks.io), [another one](/docs "With a title"), + https://www.liveblocks.io, and . + `, + { + Link: ({ href, title, children }) => ( + + {children} + + ), + }, + (root) => { + const links = root.querySelectorAll("a"); + expect(links).toHaveLength(4); + + expect(links[0]).toHaveAttribute("data-link"); + expect(links[0]).toHaveAttribute("href", "https://www.liveblocks.io"); + expect(links[0]?.textContent).toBe("link"); + + expect(links[1]).toHaveAttribute("data-link"); + expect(links[1]).toHaveAttribute("href", "/docs"); + expect(links[1]).toHaveAttribute("title", "With a title"); + expect(links[1]?.textContent).toBe("another one"); + + expect(links[2]).toHaveAttribute("data-link"); + expect(links[2]).toHaveAttribute("href", "https://www.liveblocks.io"); + expect(links[2]?.textContent).toBe("https://www.liveblocks.io"); + + expect(links[3]).toHaveAttribute("data-link"); + expect(links[3]).toHaveAttribute("href", "https://www.liveblocks.io"); + expect(links[3]?.textContent).toBe("https://www.liveblocks.io"); + } + ); + }); + + test("lists", () => { + assert( + ` + - A list item + 1. A list item + 2. Another list item + 3. Yet another list item + - Another list item + - [ ] A task list item + - [x] A completed task list item + - [x] Another completed task list item + - Yet another list item + 1. A list item + * Another list item + + [x] Yet another list item + `, + { + List: ({ items, type, start }) => { + const List = type === "ordered" ? "ol" : "ul"; + + return ( + + {items.map((item, index) => ( +
  • {item.children}
  • + ))} +
    + ); + }, + }, + (root) => { + const rootList = root.querySelector(":scope > ul"); + expect(rootList).toHaveAttribute("data-list"); + + const rootListItems = root.querySelectorAll(":scope > ul > li"); + expect(rootListItems).toHaveLength(3); + + expect(rootListItems[0]?.textContent).toContain("A list item"); + + const firstNestedList = rootListItems[0]?.querySelector("ol"); + expect(firstNestedList).toHaveAttribute("data-list"); + + const firstNestedListItems = firstNestedList?.querySelectorAll("li"); + expect(firstNestedListItems).toHaveLength(3); + + expect(firstNestedListItems?.[0]?.textContent).toBe("A list item"); + expect(firstNestedListItems?.[1]?.textContent).toBe( + "Another list item" + ); + expect(firstNestedListItems?.[2]?.textContent).toBe( + "Yet another list item" + ); + + const secondRootListItem = rootListItems[1]; + expect(secondRootListItem?.textContent).toContain( + "Another list item" + ); + + const secondNestedList = secondRootListItem?.querySelector("ul"); + expect(secondNestedList).toHaveAttribute("data-list"); + + const secondNestedListItems = + secondNestedList?.querySelectorAll("li"); + expect(secondNestedListItems).toHaveLength(3); + + expect(secondNestedListItems?.[0]?.textContent).toBe( + " A task list item" + ); + expect( + secondNestedListItems?.[0]?.querySelector("input[type='checkbox']") + ).not.toBeChecked(); + expect(secondNestedListItems?.[1]?.textContent).toBe( + " A completed task list item" + ); + expect( + secondNestedListItems?.[1]?.querySelector("input[type='checkbox']") + ).toBeChecked(); + expect(secondNestedListItems?.[2]?.textContent).toBe( + " Another completed task list item" + ); + expect( + secondNestedListItems?.[2]?.querySelector("input[type='checkbox']") + ).toBeChecked(); + + const thirdRootListItem = rootListItems[2]; + expect(thirdRootListItem?.textContent).toContain( + "Yet another list item" + ); + + const thirdNestedLists = + thirdRootListItem?.querySelectorAll("ol, ul"); + expect(thirdNestedLists).toHaveLength(3); + + expect(thirdNestedLists?.[0]).toHaveAttribute("data-list", "ordered"); + expect(thirdNestedLists?.[0]?.querySelector("li")?.textContent).toBe( + "A list item" + ); + + expect(thirdNestedLists?.[1]).toHaveAttribute( + "data-list", + "unordered" + ); + expect(thirdNestedLists?.[1]?.querySelector("li")?.textContent).toBe( + "Another list item" + ); + + expect(thirdNestedLists?.[2]).toHaveAttribute( + "data-list", + "unordered" + ); + expect(thirdNestedLists?.[2]?.querySelector("li")?.textContent).toBe( + " Yet another list item" + ); + expect( + thirdNestedLists?.[2]?.querySelector("input[type='checkbox']") + ).toBeChecked(); + } + ); + }); + + test("blockquotes", () => { + assert( + ` + > A blockquote. + + > Another one which spans + > + > multiple paragraphs. + + > Yet another which + > + > > is nested. + `, + { + Blockquote: ({ children }) => ( +
    {children}
    + ), + }, + (root) => { + const blockquotes = root.querySelectorAll("blockquote"); + expect(blockquotes).toHaveLength(4); + + expect(blockquotes[0]?.textContent).toBe("A blockquote."); + expect(blockquotes[0]).toHaveAttribute("data-blockquote"); + expect(blockquotes[1]?.innerHTML).toEqual( + "

    Another one which spans

    multiple paragraphs.

    " + ); + expect(blockquotes[1]).toHaveAttribute("data-blockquote"); + expect((blockquotes[2]?.firstChild as HTMLElement).tagName).toEqual( + "P" + ); + expect((blockquotes[2]?.firstChild as HTMLElement).innerHTML).toEqual( + "Yet another which" + ); + expect(blockquotes[2]).toHaveAttribute("data-blockquote"); + expect(blockquotes[3]?.innerHTML).toEqual("

    is nested.

    "); + expect(blockquotes[3]).toHaveAttribute("data-blockquote"); + } + ); + }); + + test("code blocks", () => { + assert( + ` + \`\`\` + p { + color: #000; + } + \`\`\` + + \`\`\`javascript + const a = 2; + \`\`\` + `, + { + CodeBlock: ({ code, language }) => ( +
    +              {code}
    +            
    + ), + }, + (root) => { + const codeBlocks = root.querySelectorAll("pre"); + expect(codeBlocks).toHaveLength(2); + + expect(codeBlocks[0]?.textContent).toBe("p {\n color: #000;\n}"); + expect(codeBlocks[0]).toHaveAttribute("data-code-block"); + expect(codeBlocks[1]?.textContent).toBe("const a = 2;"); + expect(codeBlocks[1]).toHaveAttribute("data-code-block"); + expect(codeBlocks[1]?.querySelector("code")).toHaveAttribute( + "data-language", + "javascript" + ); + } + ); + }); + + test("images", () => { + assert( + ` + ![An image](https://www.liveblocks.io/favicon.svg) + `, + { + Image: ({ src, alt }) => {alt}, + }, + (root) => { + const image = root.querySelector("img"); + + expect(image).toHaveAttribute( + "src", + "https://www.liveblocks.io/favicon.svg" + ); + expect(image).toHaveAttribute("alt", "An image"); + } + ); + }); + + test("tables", () => { + assert( + ` + | Feature | Example | Notes | + | ------------- | ------------------------------------ | ------------------------- | + | Link | [Liveblocks](https://liveblocks.io/) | External link | + | Inline code | \`const a = 2;\` | Code inside table | + | Bold text | **Important** | Styling test | + | Italic text | _Emphasis_ | Test italic inside tables | + | Strikethrough | ~~Deprecated~~ | Show removal | + `, + { + Table: ({ headings, rows }) => ( + + + + {headings.map((heading, index) => ( + + ))} + + + + {rows.map((row, index) => ( + + {row.map((cell, index) => ( + + ))} + + ))} + +
    {heading.children}
    {cell.children}
    + ), + }, + (root) => { + const table = root.querySelector("table"); + + expect(table).toHaveAttribute("data-table"); + + const headings = table?.querySelectorAll("th"); + expect(headings).toHaveLength(3); + + expect(headings?.[0]?.textContent).toBe("Feature"); + expect(headings?.[1]?.textContent).toBe("Example"); + expect(headings?.[2]?.textContent).toBe("Notes"); + + const rows = table?.querySelectorAll("tbody tr"); + expect(rows).toHaveLength(5); + + const firstRowCells = rows?.[0]?.querySelectorAll("td"); + expect(firstRowCells).toHaveLength(3); + + expect(firstRowCells?.[0]?.textContent).toBe("Link"); + expect(firstRowCells?.[1]?.textContent).toBe("Liveblocks"); + expect(firstRowCells?.[1]?.querySelector("a")).toHaveAttribute( + "href", + "https://liveblocks.io/" + ); + expect(firstRowCells?.[2]?.textContent).toBe("External link"); + + const secondRowCells = rows?.[1]?.querySelectorAll("td"); + expect(secondRowCells).toHaveLength(3); + + expect(secondRowCells?.[0]?.textContent).toBe("Inline code"); + expect(secondRowCells?.[1]?.textContent).toBe("const a = 2;"); + expect(secondRowCells?.[1]?.querySelector("code")?.textContent).toBe( + "const a = 2;" + ); + expect(secondRowCells?.[2]?.textContent).toBe("Code inside table"); + + const thirdRowCells = rows?.[2]?.querySelectorAll("td"); + expect(thirdRowCells).toHaveLength(3); + + expect(thirdRowCells?.[0]?.textContent).toBe("Bold text"); + expect(thirdRowCells?.[1]?.textContent).toBe("Important"); + expect(thirdRowCells?.[1]?.querySelector("strong")?.textContent).toBe( + "Important" + ); + expect(thirdRowCells?.[2]?.textContent).toBe("Styling test"); + + const fourthRowCells = rows?.[3]?.querySelectorAll("td"); + expect(fourthRowCells).toHaveLength(3); + + expect(fourthRowCells?.[0]?.textContent).toBe("Italic text"); + expect(fourthRowCells?.[1]?.textContent).toBe("Emphasis"); + expect(fourthRowCells?.[1]?.querySelector("em")?.textContent).toBe( + "Emphasis" + ); + expect(fourthRowCells?.[2]?.textContent).toBe( + "Test italic inside tables" + ); + + const fifthRowCells = rows?.[4]?.querySelectorAll("td"); + expect(fifthRowCells).toHaveLength(3); + + expect(fifthRowCells?.[0]?.textContent).toBe("Strikethrough"); + expect(fifthRowCells?.[1]?.textContent).toBe("Deprecated"); + expect(fifthRowCells?.[1]?.querySelector("del")?.textContent).toBe( + "Deprecated" + ); + expect(fifthRowCells?.[2]?.textContent).toBe("Show removal"); + } + ); + }); + + test("separators", () => { + assert( + ` + *** + + ---- + + _____ + `, + { + Separator: () =>
    , + }, + (root) => { + const separators = root.querySelectorAll("hr"); + expect(separators).toHaveLength(3); + + separators.forEach((separator) => { + expect(separator).toHaveAttribute("data-separator"); + }); + } + ); + }); + }); }); diff --git a/packages/liveblocks-react-ui/src/styles/dark/index.css b/packages/liveblocks-react-ui/src/styles/dark/index.css index 9027fb5cb15..b0f0f030e36 100644 --- a/packages/liveblocks-react-ui/src/styles/dark/index.css +++ b/packages/liveblocks-react-ui/src/styles/dark/index.css @@ -32,4 +32,30 @@ --lb-background: #2a2a2a; --lb-foreground-contrast: 10%; } + + .lb-ai-chat-pending { + animation-name: lb-animation-shimmer-text-invert; + } +} + +@keyframes lb-animation-shimmer-text-invert { + from, + to { + mask-image: linear-gradient( + 90deg, + rgb(0 0 0 / 50%) 36%, + rgb(0 0 0 / 95%) 48%, + rgb(0 0 0 / 95%) 52%, + rgb(0 0 0 / 50%) 64% + ); + mask-size: 300% 100%; + } + + from { + mask-position: 100% 0; + } + + to { + mask-position: 0% 0; + } } diff --git a/packages/liveblocks-react-ui/src/styles/index.css b/packages/liveblocks-react-ui/src/styles/index.css index 777f9749214..4ccc461a60a 100644 --- a/packages/liveblocks-react-ui/src/styles/index.css +++ b/packages/liveblocks-react-ui/src/styles/index.css @@ -41,6 +41,12 @@ var(--lb-foreground-contrast), 50 ); + --lb-background-foreground-subtle: color-mix-scale( + var(--lb-background), + var(--lb-foreground), + var(--lb-foreground-contrast), + 100 + ); --lb-background-accent-faint: color-mix-scale( var(--lb-background), var(--lb-accent), @@ -496,7 +502,7 @@ block-size: calc(0.8 * var(--lb-icon-size)); margin-inline-start: -1px; margin-block-start: 1px; - color: var(--lb-foreground-moderate); + opacity: 0.65; } @media (prefers-reduced-motion: no-preference) { @@ -1117,7 +1123,7 @@ *************************************/ :is(.lb-avatar, .lb-name):where([data-loading]) { - animation: lb-animation-shimmer 8s linear infinite; + animation: lb-animation-shimmer-skeleton 8s linear infinite; } /************************************* @@ -1183,6 +1189,10 @@ background: var(--lb-accent-subtle); } +.lb-mention-symbol { + display: contents; +} + /************************************* * Composer * *************************************/ @@ -2400,9 +2410,11 @@ *************************************/ .lb-code-block { + position: relative; min-inline-size: 0; border: 1px solid var(--lb-foreground-subtle); border-radius: var(--lb-radius); + isolation: isolate; :where(.lb-code-block-header) { display: flex; @@ -2535,17 +2547,51 @@ .lb-ai-chat-user-message { :where(.lb-ai-chat-message-content) { - min-block-size: calc(1lh + var(--lb-spacing)); - padding: calc(0.5 * var(--lb-spacing)) var(--lb-spacing); + --lb-dynamic-background: var(--lb-background-foreground-faint); + --lb-ai-chat-message-content-horizontal-padding: calc( + 0.9375 * var(--lb-spacing) + ); + --lb-ai-chat-message-content-vertical-padding: calc( + 0.5625 * var(--lb-spacing) + ); + + min-block-size: calc( + 1lh + 2 * var(--lb-ai-chat-message-content-vertical-padding) + ); + padding: var(--lb-ai-chat-message-content-vertical-padding) + var(--lb-ai-chat-message-content-horizontal-padding); /** * Exactly half the height (line-height + padding) of a one line bubble, * so that it doesn't change when the text wraps and the bubble becomes taller. */ - border-radius: calc((1lh + var(--lb-spacing)) / 2); - background: var(--lb-background-foreground-faint); + border-radius: calc( + (1lh + 2 * var(--lb-ai-chat-message-content-vertical-padding)) / 2 + ); + background: var(--lb-dynamic-background); color: var(--lb-foreground); line-height: var(--lb-line-height); + + /** + * Rich messages get more padding than plain text ones. + */ + &:where( + :has(h1, h2, h3, h4, h5, h6, ul, ol, blockquote, table, .lb-code-block) + ) { + --lb-ai-chat-message-content-horizontal-padding: calc( + 1.375 * var(--lb-spacing) + ); + --lb-ai-chat-message-content-vertical-padding: calc( + 1.125 * var(--lb-spacing) + ); + } + + :where(.lb-code-block) { + --lb-dynamic-background: var(--lb-background-foreground-subtle); + + border: none; + background: var(--lb-dynamic-background); + } } } @@ -2762,35 +2808,52 @@ .lb-ai-chat-pending { user-select: none; - animation: lb-animation-shimmer-small 5s linear infinite; + animation: lb-animation-shimmer-text 2s cubic-bezier(0.1, 0, 0.9, 1) infinite; } -.lb-ai-chat-message-thinking, -.lb-ai-chat-message-reasoning { +.lb-ai-chat-message-thinking { inline-size: fit-content; max-inline-size: 100%; color: var(--lb-foreground-tertiary); + transition-property: color; - &:where(.lb-ai-chat-pending), - :where(.lb-ai-chat-pending) { + &:where(.lb-ai-chat-pending) { color: var(--lb-foreground); } } .lb-ai-chat-message-reasoning { + position: relative; margin-block-end: calc(0.75 * var(--lb-spacing)); + &::after { + content: ""; + position: absolute; + inset: calc(-0.375 * var(--lb-spacing)); + border-radius: calc(0.75 * var(--lb-radius)); + pointer-events: none; + transition-property: box-shadow; + } + + &:where(:has(.lb-collapsible-trigger:focus-visible)) { + z-index: 1; + + &::after { + box-shadow: + var(--lb-dynamic-background) 0 0 0 2px, + var(--lb-accent) 0 0 0 4px; + } + } + :where(.lb-collapsible-trigger) { display: flex; gap: calc(0.25 * var(--lb-spacing)); align-items: center; + color: var(--lb-foreground-tertiary); + transition-property: color; - &:where(:focus-visible) { - color: var(--lb-accent); - - :where(.lb-collapsible-chevron) { - color: var(--lb-accent-moderate); - } + &:where(.lb-ai-chat-pending) { + color: var(--lb-foreground); } } @@ -2814,8 +2877,6 @@ .lb-ai-tool { position: relative; - border-radius: var(--lb-radius); - box-shadow: inset 0 0 0 1px var(--lb-foreground-subtle); &::after { content: ""; @@ -2839,11 +2900,9 @@ .lb-ai-tool-header { display: flex; - gap: calc(0.3125 * var(--lb-spacing)); + gap: calc(0.25 * var(--lb-spacing)); align-items: center; inline-size: 100%; - block-size: calc($lb-button-size + var(--lb-spacing)); - padding-inline: calc(0.5 * var(--lb-spacing)); } .lb-ai-tool-header-icon-container, @@ -2872,26 +2931,95 @@ .lb-ai-tool-header-title { @include truncate; - - margin-inline-end: calc(0.25 * var(--lb-spacing)); - color: var(--lb-foreground-secondary); - font-size: 0.9375em; - - &:where(:first-child) { - margin-inline-start: calc(0.3125 * var(--lb-spacing)); - } } .lb-ai-tool-header-status { flex: none; - margin-inline-start: auto; - margin-inline-end: calc(0.1875 * var(--lb-spacing)); color: var(--lb-foreground-moderate); } -.lb-ai-tool:where([data-result="error"]) { - box-shadow: inset 0 0 0 1px var(--lb-destructive-moderate); +.lb-ai-tool:where(.lb-ai-tool\:variant-block) { + border-radius: var(--lb-radius); + box-shadow: inset 0 0 0 1px var(--lb-foreground-subtle); + + :where(.lb-ai-tool-header) { + block-size: calc($lb-button-size + var(--lb-spacing)); + padding-inline: calc(0.5 * var(--lb-spacing)); + color: var(--lb-foreground-secondary); + } + + :where(.lb-ai-tool-header-title) { + font-size: 0.9375em; + + &:where(:first-child) { + margin-inline-start: calc(0.3125 * var(--lb-spacing)); + } + } + :where(.lb-ai-tool-header-status) { + margin-inline-start: auto; + margin-inline-end: calc(0.1875 * var(--lb-spacing)); + } + + :where(.lb-ai-tool-content:not(:empty)) { + padding: 0 calc(0.75 * var(--lb-spacing)) calc(0.75 * var(--lb-spacing)); + } + + &:where([data-result="error"]) { + box-shadow: inset 0 0 0 1px var(--lb-destructive-moderate); + } +} + +.lb-ai-tool:where(.lb-ai-tool\:variant-minimal) { + &::after { + inset: calc(-0.375 * var(--lb-spacing)); + border-radius: calc(0.75 * var(--lb-radius)); + } + + :where(.lb-ai-tool-header) { + position: relative; + inline-size: fit-content; + max-inline-size: 100%; + block-size: $lb-button-size; + color: var(--lb-foreground-tertiary); + transition-property: color; + + &:where(.lb-ai-chat-pending), + :where(.lb-ai-chat-pending) { + color: var(--lb-foreground); + } + + /* Invisibly increase the header's hit target size */ + &::before { + content: ""; + position: absolute; + inset: calc(-0.375 * var(--lb-spacing)); + z-index: -1; + } + } + + :where(.lb-ai-tool-header-icon-container) { + margin-inline-start: calc(-0.125 * var(--lb-spacing)); + } + + :where(.lb-ai-tool-header-title) { + &:where(:not(:first-child)) { + margin-inline-start: calc(-0.0625 * var(--lb-spacing)); + } + } + + :where(.lb-ai-tool-content:not(:empty)) { + padding-block-start: calc(0.5 * var(--lb-spacing)); + } + + &:where([data-result="cancelled"]) { + :where(.lb-ai-tool-header) { + color: var(--lb-foreground-moderate); + } + } +} + +.lb-ai-tool:where([data-result="error"]) { &:where(:has(.lb-ai-tool-header:focus-visible)) { &::after { box-shadow: @@ -2900,13 +3028,18 @@ } } + :where(.lb-ai-tool-header), :where(.lb-ai-tool-header-status) { color: var(--lb-destructive); } -} -.lb-ai-tool-content:where(:not(:empty)) { - padding: 0 calc(0.75 * var(--lb-spacing)) calc(0.75 * var(--lb-spacing)); + :where(.lb-ai-tool-icon) { + background: var(--lb-destructive-subtle); + } + + :where(.lb-ai-tool-header-icon-container) { + color: var(--lb-destructive-secondary); + } } .lb-ai-tool-inspector { @@ -2977,7 +3110,7 @@ } } -@keyframes lb-animation-shimmer { +@keyframes lb-animation-shimmer-skeleton { from, to { mask-image: linear-gradient( @@ -2999,25 +3132,25 @@ } } -@keyframes lb-animation-shimmer-small { +@keyframes lb-animation-shimmer-text { from, to { mask-image: linear-gradient( 90deg, - #000 0%, - rgb(0 0 0 / 50%) 18%, - rgb(0 0 0 / 50%) 82%, - #000 100% + rgb(0 0 0 / 75%) 36%, + rgb(0 0 0 / 20%) 48%, + rgb(0 0 0 / 20%) 52%, + rgb(0 0 0 / 75%) 64% ); mask-size: 300% 100%; } from { - mask-position: 200% 0; + mask-position: 100% 0; } to { - mask-position: -100% 0; + mask-position: 0% 0; } } diff --git a/packages/liveblocks-react-ui/src/utils/find-last-index.ts b/packages/liveblocks-react-ui/src/utils/find-last-index.ts deleted file mode 100644 index 946d81b98bf..00000000000 --- a/packages/liveblocks-react-ui/src/utils/find-last-index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export function findLastIndex( - array: T[], - predicate: (member: T) => unknown -) { - let index = array.length - 1; - - while (index >= 0) { - const element = array[index]; - - if (element && predicate(element)) { - return index; - } - - index--; - } - - return -1; -} diff --git a/packages/liveblocks-react/package.json b/packages/liveblocks-react/package.json index 27695db5c45..2da8d337431 100644 --- a/packages/liveblocks-react/package.json +++ b/packages/liveblocks-react/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react", - "version": "3.3.4", + "version": "3.4.0", "description": "A set of React hooks and providers to use Liveblocks declaratively. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -61,8 +61,8 @@ "showdeps": "depcruise src --include-only '^src' --exclude='__tests__' --output-type dot | dot -T svg > /tmp/dependency-graph.svg && open /tmp/dependency-graph.svg" }, "dependencies": { - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4" + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0" }, "peerDependencies": { "@types/react": "*", diff --git a/packages/liveblocks-react/src/__tests__/useInitial.test.tsx b/packages/liveblocks-react/src/__tests__/useInitial.test.tsx new file mode 100644 index 00000000000..49df1c7036a --- /dev/null +++ b/packages/liveblocks-react/src/__tests__/useInitial.test.tsx @@ -0,0 +1,256 @@ +import { renderHook } from "@testing-library/react"; + +import { useInitial, useInitialUnlessFunction } from "../lib/use-initial"; + +describe("useInitial", () => { + test("freezes initial object and maintains reference equality", () => { + const { result, rerender } = renderHook((props) => useInitial(props), { + initialProps: { count: 0 }, + }); + + const firstResult = result.current; + expect(firstResult).toEqual({ count: 0 }); + + // Change the value and re-render + rerender({ count: 1 }); + + const secondResult = result.current; + expect(secondResult).toEqual({ count: 0 }); // Still the original value + expect(secondResult).toBe(firstResult); // Same reference + }); + + test("freezes initial primitive value across re-renders", () => { + const { result, rerender } = renderHook(({ count }) => useInitial(count), { + initialProps: { count: 0 }, + }); + + const firstResult = result.current; + expect(firstResult).toEqual(0); + + // Change the value and re-render + rerender({ count: 1 }); + + const secondResult = result.current; + expect(secondResult).toEqual(0); // Still the original value + expect(secondResult).toBe(firstResult); // Same reference + }); + + test("freezes initial function and ignores updates", () => { + const fn1 = jest.fn((a: number, b: string) => `${a}-${b}`); + const fn2 = jest.fn((a: number, b: string) => `${b}-${a}`); + + const { result, rerender } = renderHook((fn) => useInitial(fn), { + initialProps: fn1, + }); + + const frozenFn = result.current; + + // Call the frozen function + const result1 = frozenFn(42, "test"); + expect(result1).toBe("42-test"); + expect(fn1).toHaveBeenCalledWith(42, "test"); + + // Update with new function - but useInitial keeps the original + rerender(fn2); + + expect(result.current).toBe(frozenFn); // Same frozen reference + expect(fn2).not.toHaveBeenCalled(); // New function never called + + // Calling it still uses the original frozen function + const result2 = result.current(99, "hello"); + expect(result2).toBe("99-hello"); + expect(fn1).toHaveBeenCalledWith(99, "hello"); + expect(fn2).not.toHaveBeenCalled(); // Still never called + }); + + test("re-evaluates when roomId changes", () => { + const { result, rerender } = renderHook( + ({ count, roomId }) => useInitial(count, roomId), + { initialProps: { count: 7, roomId: "room1" } } + ); + + expect(result.current).toEqual(7); + + // Change value but keep same roomId - should stay frozen + rerender({ count: 13, roomId: "room1" }); + expect(result.current).toBe(7); // Same value + + // Change roomId - should re-evaluate with new value + rerender({ count: 42, roomId: "room2" }); + expect(result.current).toEqual(42); // Count updated because roomId changed + }); + + test("re-evaluates functions when roomId changes", () => { + const fn1 = jest.fn(() => "result1"); + const fn2 = jest.fn(() => "result2"); + const fn3 = jest.fn(() => "result3"); + + const { result, rerender } = renderHook( + ({ fn, roomId }) => useInitial(fn, roomId), + { initialProps: { fn: fn1, roomId: "room1" } } + ); + + const frozenFn1 = result.current; + expect(frozenFn1()).toBe("result1"); + expect(fn1).toHaveBeenCalledTimes(1); + + // Change function but keep same roomId - should stay frozen to original + rerender({ fn: fn2, roomId: "room1" }); + expect(result.current).toBe(frozenFn1); // Same reference + expect(result.current()).toBe("result1"); // Still original function + expect(fn2).not.toHaveBeenCalled(); + + // Change roomId - should re-evaluate with new function + rerender({ fn: fn3, roomId: "room2" }); + const frozenFn2 = result.current; + expect(frozenFn2).not.toBe(frozenFn1); // Different reference + expect(frozenFn2()).toBe("result3"); // New function + expect(fn3).toHaveBeenCalledTimes(1); + expect(fn2).not.toHaveBeenCalled(); // fn2 was never frozen + }); +}); + +describe("useInitialUnlessFunction", () => { + test("freezes non-function values like useInitial", () => { + const { result, rerender } = renderHook( + (props) => useInitialUnlessFunction(props), + { initialProps: { count: 0 } } + ); + + const firstResult = result.current; + expect(firstResult).toEqual({ count: 0 }); + + rerender({ count: 1 }); + + expect(result.current).toBe(firstResult); // Same reference + }); + + test("creates stable wrapper that calls latest function", () => { + const fn1 = jest.fn(() => "result1"); + const fn2 = jest.fn(() => "result2"); + + const { result, rerender } = renderHook( + ({ fn }) => useInitialUnlessFunction(fn), + { initialProps: { fn: fn1 } } + ); + + const wrappedFn = result.current; + expect(typeof wrappedFn).toBe("function"); + + // Call the wrapped function + const result1 = wrappedFn(); + expect(result1).toBe("result1"); + expect(fn1).toHaveBeenCalledTimes(1); + + // Update with new function - wrapper should stay the same + rerender({ fn: fn2 }); + + expect(result.current).toBe(wrappedFn); // Same wrapper reference + + // But calling it should use the latest function + const result2 = result.current(); + expect(result2).toBe("result2"); + expect(fn2).toHaveBeenCalledTimes(1); + expect(fn1).toHaveBeenCalledTimes(1); // Not called again + }); + + test("passes arguments through stable wrapper to latest function", () => { + const fn1 = jest.fn((a: number, b: string) => `${a}-${b}`); + const fn2 = jest.fn((a: number, b: string) => `${b}-${a}`); + + const { result, rerender } = renderHook( + ({ fn }) => useInitialUnlessFunction(fn), + { initialProps: { fn: fn1 } } + ); + + const wrappedFn = result.current; + + // Call with arguments + const result1 = wrappedFn(42, "test"); + expect(result1).toBe("42-test"); + expect(fn1).toHaveBeenCalledWith(42, "test"); + + // Update function and call again + rerender({ fn: fn2 }); + const result2 = result.current(99, "hello"); + expect(result2).toBe("hello-99"); + expect(fn2).toHaveBeenCalledWith(99, "hello"); + }); + + test("maintains stable wrapper despite roomId changes", () => { + const fn1 = jest.fn(() => "fn1"); + const fn2 = jest.fn(() => "fn2"); + const fn3 = jest.fn(() => "fn3"); + + const { result, rerender } = renderHook( + ({ fn, roomId }) => useInitialUnlessFunction(fn, roomId), + { initialProps: { fn: fn1, roomId: "room1" } } + ); + + const wrapper1 = result.current; + expect(wrapper1()).toBe("fn1"); + + // Change function but keep same roomId - should use same wrapper + rerender({ fn: fn2, roomId: "room1" }); + expect(result.current).toBe(wrapper1); // Same wrapper + expect(result.current()).toBe("fn2"); // But calls latest function + + // Change roomId - the useCallback should re-evaluate with new roomId dependency + // but since roomId isn't actually used in the useCallback dependency array, + // the wrapper reference stays the same (which is the current behavior) + rerender({ fn: fn3, roomId: "room2" }); + const wrapper2 = result.current; + // The implementation currently doesn't create a new wrapper when roomId changes + // for function values because roomId isn't in the useCallback deps + expect(wrapper2()).toBe("fn3"); // Should still call the latest function + }); + + test("handles type changes from function to non-function when roomId changes", () => { + const fn = jest.fn(() => "function result"); + const nonFunction = "string value"; + + const { result, rerender } = renderHook( + ({ value, roomId }) => useInitialUnlessFunction(value, roomId), + { initialProps: { value: fn as unknown, roomId: "room1" } } + ); + + // Initially a function - should get a wrapper + const wrapper = result.current; + expect(typeof wrapper).toBe("function"); + // @ts-expect-error wrapper is a function at this point + expect(wrapper()).toBe("function result"); + + // Change to non-function with same roomId - should keep wrapper + rerender({ value: nonFunction, roomId: "room1" }); + expect(result.current).toBe(wrapper); // Same wrapper reference + + // Change roomId with non-function - should get the non-function value directly + rerender({ value: nonFunction, roomId: "room2" }); + expect(result.current).toBe("string value"); // Direct non-function value + expect(typeof result.current).toBe("string"); + }); + + test("handles type changes from non-function to function when roomId changes", () => { + const nonFunction = "string value"; + const fn = jest.fn(() => "function result"); + + const { result, rerender } = renderHook( + ({ value, roomId }) => useInitialUnlessFunction(value, roomId), + { initialProps: { value: nonFunction as unknown, roomId: "room1" } } + ); + + // Initially a non-function - should get the value directly + expect(result.current).toBe("string value"); + expect(typeof result.current).toBe("string"); + + // Change to function with same roomId - should keep non-function + rerender({ value: fn, roomId: "room1" }); + expect(result.current).toBe("string value"); // Still the original frozen value + + // Change roomId with function - should get a function wrapper + rerender({ value: fn, roomId: "room2" }); + expect(typeof result.current).toBe("function"); + // @ts-expect-error result.current is a function at this point + expect(result.current()).toBe("function result"); + }); +}); diff --git a/packages/liveblocks-react/src/lib/use-initial.ts b/packages/liveblocks-react/src/lib/use-initial.ts index 179bce226cc..fcb4214e2b8 100644 --- a/packages/liveblocks-react/src/lib/use-initial.ts +++ b/packages/liveblocks-react/src/lib/use-initial.ts @@ -1,19 +1,16 @@ -import type { Reducer } from "react"; -import { useCallback, useReducer } from "react"; +import { useCallback, useMemo } from "react"; import { useLatest } from "./use-latest"; -const noop = (state: T) => state; - /** * "Freezes" a given value, so that it will return the same value/instance on * each subsequent render. This can be used to freeze "initial" values for * custom hooks, much like how `useState(initialState)` or * `useRef(initialValue)` works. */ -export function useInitial(value: T): T { - // Equivalent to useState(() => value)[0], but slightly more low-level - return useReducer>(noop, value)[0]; +export function useInitial(value: T, roomId?: string): T { + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => value, [roomId]); } /** @@ -22,23 +19,24 @@ export function useInitial(value: T): T { * re-renders, but one which will always call the _latest_ provided callback * instance. */ -export function useInitialUnlessFunction(latestValue: T): T { - const frozenValue = useInitial(latestValue); +export function useInitialUnlessFunction( + latestValue: T, + roomId?: string +): T { + const frozenValue = useInitial(latestValue, roomId); + + type Fn = T & ((...args: unknown[]) => unknown); + const ref = useLatest(latestValue as Fn); + // eslint-disable-next-line react-hooks/exhaustive-deps + const wrapper = useCallback( + ((...args: unknown[]) => ref.current(...args)) as Fn, + [ref] + ); - // Normally the Rules of Hooks™ dictate that you should not call hooks - // conditionally. In this case, we're good here, because the same code path - // will always be taken on every subsequent render here, because we've frozen - // the value. - /* eslint-disable react-hooks/rules-of-hooks */ + // Return the wrapper only if the frozen value is a function if (typeof frozenValue === "function") { - type Fn = T & ((...args: unknown[]) => unknown); - const ref = useLatest(latestValue as Fn); - // eslint-disable-next-line react-hooks/exhaustive-deps - return useCallback(((...args: unknown[]) => ref.current(...args)) as Fn, [ - ref, - ]); + return wrapper; } else { return frozenValue; } - /* eslint-enable react-hooks/rules-of-hooks */ } diff --git a/packages/liveblocks-react/src/room.tsx b/packages/liveblocks-react/src/room.tsx index 7292b3cac75..dd7cb5284b8 100644 --- a/packages/liveblocks-react/src/room.tsx +++ b/packages/liveblocks-react/src/room.tsx @@ -597,12 +597,15 @@ function RoomProviderInner< } // Note: We'll hold on to the initial value given here, and ignore any - // changes to this argument in subsequent renders - const frozenProps = useInitial({ - initialPresence: props.initialPresence, - initialStorage: props.initialStorage, - autoConnect: props.autoConnect ?? typeof window !== "undefined", - }) as EnterOptions; + // changes to this argument in subsequent renders, except when roomId changes + const frozenProps = useInitial( + { + initialPresence: props.initialPresence, + initialStorage: props.initialStorage, + autoConnect: props.autoConnect ?? typeof window !== "undefined", + }, + roomId + ) as EnterOptions; const [{ room }, setRoomLeavePair] = useState(() => stableEnterRoom(roomId, { diff --git a/packages/liveblocks-redux/package.json b/packages/liveblocks-redux/package.json index 048786b5984..24bb81ff111 100644 --- a/packages/liveblocks-redux/package.json +++ b/packages/liveblocks-redux/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/redux", - "version": "3.3.4", + "version": "3.4.0", "description": "A store enhancer to integrate Liveblocks into Redux stores. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -33,8 +33,8 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4" + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0" }, "peerDependencies": { "redux": "^4 || ^5" diff --git a/packages/liveblocks-yjs/package.json b/packages/liveblocks-yjs/package.json index 0b45e19ea46..c61258a9113 100644 --- a/packages/liveblocks-yjs/package.json +++ b/packages/liveblocks-yjs/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/yjs", - "version": "3.3.4", + "version": "3.4.0", "description": "Integrate your existing or new Yjs documents with Liveblocks.", "icense": "Apache-2.0", "type": "module", @@ -33,8 +33,8 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4", + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0", "@noble/hashes": "^1.8.0", "js-base64": "^3.7.7", "y-indexeddb": "^9.0.12" diff --git a/packages/liveblocks-zustand/package.json b/packages/liveblocks-zustand/package.json index eede92bab50..3699c24d1c0 100644 --- a/packages/liveblocks-zustand/package.json +++ b/packages/liveblocks-zustand/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/zustand", - "version": "3.3.4", + "version": "3.4.0", "description": "A middleware for Zustand to automatically synchronize your stores with Liveblocks. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -34,8 +34,8 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/client": "3.3.4", - "@liveblocks/core": "3.3.4" + "@liveblocks/client": "3.4.0", + "@liveblocks/core": "3.4.0" }, "peerDependencies": { "zustand": "^5.0.1"