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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
## vNEXT (not yet released)

## v3.20.1

### `@liveblocks/client`

- Fix a bug where sending a too large WebSocket message could sometimes
overwrite a room's top-level storage key with `initialStorage`, causing data
loss. (Thanks @watemerald for reporting!)

## v3.20.0

### All packages
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG_PUBLIC.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ list and feel free to give them credit at the end of a line, e.g.:

-->

# Week 26 (2026-06-26)

## v3.20.1

### `@liveblocks/client`

- Fix a bug where sending a too large WebSocket message could sometimes overwrite a room's top-level storage key with `initialStorage`, causing data loss. Thanks for reporting [@watemerald](https://github.com/watemerald)!

## Dashboard

- Added support for manual and automatic re-ingestion of (web) knowledge sources at specified interval.
- Streamlined display of knowledge sources and linking/unlinking of knowledge sources and copilots.

## Contributors

nimeshnayaju, pierrelevaillant, nvie

# Week 25 (2026-06-19)

## v3.20.0
Expand Down
4 changes: 4 additions & 0 deletions examples/nextjs-linear-like-issue-tracker/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: { root: __dirname },
allowedDevOrigins: ["*.ngrok-free.app", "*.ngrok.io", "*.loca.lt"],

// https://nextjs.org/blog/next-16-3-instant-navigations
cacheComponents: true,
partialPrefetching: true,
};

export default nextConfig;
1,378 changes: 674 additions & 704 deletions examples/nextjs-linear-like-issue-tracker/package-lock.json

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions examples/nextjs-linear-like-issue-tracker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@
"dependencies": {
"@ai-sdk/anthropic": "^3.0.64",
"@lexical/react": "^0.35.0",
"@liveblocks/client": "^3.19.1",
"@liveblocks/node": "^3.19.1",
"@liveblocks/node-lexical": "^3.19.1",
"@liveblocks/react": "^3.19.1",
"@liveblocks/react-lexical": "^3.19.1",
"@liveblocks/react-ui": "^3.19.1",
"@liveblocks/client": "^3.20.0",
"@liveblocks/node": "^3.20.0",
"@liveblocks/node-lexical": "^3.20.0",
"@liveblocks/react": "^3.20.0",
"@liveblocks/react-lexical": "^3.20.0",
"@liveblocks/react-ui": "^3.20.0",
"@radix-ui/react-select": "^2.1.7",
"@types/node": "20.3.3",
"@types/react-dom": "18.2.25",
Expand All @@ -27,7 +27,7 @@
"lexical": "^0.35.0",
"marked": "^14.1.4",
"nanoid": "^3.3.11",
"next": "^16.1.6",
"next": "^16.3.0-preview.5",
"node-html-parser": "^6.1.13",
"postcss": "^8.4.49",
"react": "18.2.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,42 @@
import { Suspense } from "react";
import { Room } from "@/app/Room";
import RoomErrors from "@/components/RoomErrors";
import { Issue } from "@/components/Issue";
import { Nav } from "@/components/Nav";
import { Inbox } from "@/components/Inbox";
import { DisplayWhenInboxOpen } from "@/components/InboxContext";
import { IssueShell } from "@/components/IssueShell";

export const revalidate = 0;

export default async function PageHome({
export default function PageHome({
params,
}: {
params: Promise<{ id: string }>;
}) {
return (
<div className="flex flex-row h-full">
<nav className="p-2 w-[200px] xl:w-[250px]">
<Nav />
</nav>
<main className="m-2 border flex-grow bg-neutral-50 rounded flex flex-row overflow-hidden">
<Suspense fallback={<IssueShell />}>
<IssueRoom params={params} />
</Suspense>
</main>
</div>
);
}

async function IssueRoom({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<Room issueId={id}>
<div className="flex flex-row h-full">
<nav className="p-2 w-[200px] xl:w-[250px]">
<Nav />
</nav>
<main className="m-2 border flex-grow bg-neutral-50 rounded flex flex-row overflow-hidden">
<DisplayWhenInboxOpen>
<div className="border-r w-[200px] xl:w-[300px]">
<Inbox />
</div>
</DisplayWhenInboxOpen>
<div className="flex-grow">
<Issue issueId={id} />
</div>
</main>
<DisplayWhenInboxOpen>
<div className="border-r w-[200px] xl:w-[300px]">
<Inbox />
</div>
</DisplayWhenInboxOpen>
<div className="flex-grow">
<Issue issueId={id} />
</div>
<RoomErrors />
</Room>
Expand Down
16 changes: 11 additions & 5 deletions examples/nextjs-linear-like-issue-tracker/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import { Suspense } from "react";
import { Nav } from "@/components/Nav";
import { liveblocks } from "@/liveblocks.server.config";
import { RoomWithMetadata } from "@/config";
import { IssuesList } from "@/components/IssuesList";

export const revalidate = 0;

export default async function PageIssue() {
const rooms = (await liveblocks.getRooms()).data as RoomWithMetadata[];
export default function PageIssue() {
return (
<div className="flex flex-row h-full">
<nav className="p-2 w-[200px] xl:w-[250px]">
<Nav />
</nav>
<main className="m-2 border flex-grow bg-neutral-50 rounded">
<IssuesList initialRooms={rooms} />
<Suspense fallback={<IssuesList initialRooms={[]} />}>
<IssuesListLoader />
</Suspense>
</main>
</div>
);
}

async function IssuesListLoader() {
"use cache";
const rooms = (await liveblocks.getRooms()).data as RoomWithMetadata[];
return <IssuesList initialRooms={rooms} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export function Comments() {
<ClientSideSuspense
fallback={
<>
<div className="bg-gray-100/80 animate-pulse h-[130px] rounded-lg my-6" />
<CommentsFallback />
</>
}
>
Expand All @@ -116,6 +116,12 @@ export function Comments() {
);
}

export function CommentsFallback() {
return (
<div className="bg-gray-100/80 animate-pulse h-[130px] rounded-lg my-6" />
);
}

function ThreadList() {
const { threads } = useThreads();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,13 @@ export function Editor({
<div className="relative">
<LiveblocksPlugin>
{!ready ? (
<div className="select-none cursor-wait editor-styles">
<div className="select-none cursor-wait editor-styles min-h-[24px]">
{contentFallback}
</div>
) : (
<RichTextPlugin
contentEditable={
<ContentEditable className="outline-none editor-styles" />
<ContentEditable className="outline-none editor-styles min-h-[24px]" />
}
placeholder={
<div className="absolute top-0 left-0 pointer-events-none text-neutral-500 whitespace-nowrap">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useState,
ReactNode,
useLayoutEffect,
Suspense,
} from "react";
import { usePathname } from "next/navigation";

Expand All @@ -27,14 +28,6 @@ export function useInbox() {

export function InboxProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname();

useLayoutEffect(() => {
// Reset when changing to dashboard
if (pathname === "/") {
localStorage.setItem("inboxOpen", "false");
}
}, [pathname]);

useLayoutEffect(() => {
setIsOpen(localStorage.getItem("inboxOpen") === "true");
Expand All @@ -55,11 +48,27 @@ export function InboxProvider({ children }: { children: ReactNode }) {
<InboxContext.Provider
value={{ isOpen, toggleInbox, openInbox: () => setIsOpen(true) }}
>
<Suspense fallback={null}>
<ResetInboxOnDashboard />
</Suspense>
{children}
</InboxContext.Provider>
);
}

// Reset when changing to dashboard
function ResetInboxOnDashboard() {
const pathname = usePathname();

useLayoutEffect(() => {
if (pathname === "/") {
localStorage.setItem("inboxOpen", "false");
}
}, [pathname]);

return null;
}

export function DisplayWhenInboxOpen({ children }: { children: ReactNode }) {
const { isOpen } = useInbox();

Expand Down
83 changes: 57 additions & 26 deletions examples/nextjs-linear-like-issue-tracker/src/components/Issue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,42 +20,73 @@ export async function Issue({ issueId }: { issueId: string }) {
const roomId = getRoomId(issueId);

// Get storage contents of room (e.g. issue properties) to render placeholder on load
const storagePromise = liveblocks.getStorageDocument(roomId, "json");
async function fetchStorage() {
"use cache";
const storagePromise = liveblocks.getStorageDocument(roomId, "json");
return storagePromise;
}

// Get content and convert it to markdown for displaying a placeholder
const contentHtmlPromise = withLexicalDocument(
{
roomId,
client: liveblocks,
nodes: [...ISSUE_LEXICAL_NODES],
},
async (doc) => {
let markdown = "";
async function fetchContentHtml() {
"use cache";
const contentHtmlPromise = withLexicalDocument(
{
roomId,
client: liveblocks,
nodes: [...ISSUE_LEXICAL_NODES],
},
async (doc) => {
let markdown = "";

doc.getEditorState().read(() => {
// Get markdown version of Lexical state
markdown = $convertToMarkdownString(TRANSFORMERS, undefined, true)
// Make new lines display correctly
.replace(/\n{2,}/g, (match) => "<p><br></p>".repeat(match.length - 1))
.replace(/\n(?!$)/g, "\n\n")
.replace(/(\n+)$/g, (match) => "<p><br></p>".repeat(match.length));
});
doc.getEditorState().read(() => {
// Get markdown version of Lexical state
markdown = $convertToMarkdownString(TRANSFORMERS, undefined, true)
// Make new lines display correctly
.replace(/\n{2,}/g, (match) =>
"<p><br></p>".repeat(match.length - 1)
)
.replace(/\n(?!$)/g, "\n\n")
.replace(/(\n+)$/g, (match) => "<p><br></p>".repeat(match.length));
});

// Remove all HTML tags but "p" and "br"
markdown = sanitizeHtml(markdown, {
allowedTags: ["p", "br"],
disallowedTagsMode: "escape",
});
const rawHtml = await marked(markdown);

return marked(markdown);
}
);
return sanitizeHtml(rawHtml, {
allowedTags: [
"p",
"br",
"strong",
"em",
"s",
"code",
"pre",
"blockquote",
"ul",
"ol",
"li",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"a",
],
allowedAttributes: {
a: ["href", "name", "target", "rel"],
},
allowedSchemes: ["http", "https", "mailto"],
});
}
);
return contentHtmlPromise;
}

let error;
let results;

try {
results = await Promise.all([storagePromise, contentHtmlPromise]);
results = await Promise.all([fetchStorage(), fetchContentHtml()]);
} catch (err) {
console.log(err);
error = err;
Expand Down
Loading
Loading