From fa0508e5524313911c8782a745876b52b378e6e0 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Wed, 11 Mar 2026 14:12:37 +0100 Subject: [PATCH 1/2] Remove `engine` flag (#3171) --- CHANGELOG.md | 18 + .../stress/[roomId]/engine/[engine].tsx | 537 ---------------- .../pages/storage/stress/[roomId]/index.tsx | 604 ++++++++++++++---- packages/liveblocks-core/src/client.ts | 15 +- packages/liveblocks-core/src/room.ts | 13 +- .../src/__tests__/client.test.ts | 35 +- packages/liveblocks-node/src/client.ts | 8 +- packages/liveblocks-react/src/types/index.ts | 5 +- 8 files changed, 520 insertions(+), 715 deletions(-) delete mode 100644 e2e/next-sandbox/pages/storage/stress/[roomId]/engine/[engine].tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index db39825612..ab60d9dadb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ ## vNEXT (not yet released) +## v3.15.2 + +### `@liveblocks/client` + +- Deprecate the `engine` option on `enterRoom()`. This flag no longer has any + effect. + +### `@liveblocks/react` + +- Deprecate the `engine` prop on `RoomProvider`. This flag no longer has any + effect. + +### `@liveblocks/node` + +- Deprecate the `engine` option on `createRoom()`. This flag no longer has any + effect. +- Stop sending the `engine` field in the room creation request body. + ## v3.15.1 ### `@liveblocks/react-ui` diff --git a/e2e/next-sandbox/pages/storage/stress/[roomId]/engine/[engine].tsx b/e2e/next-sandbox/pages/storage/stress/[roomId]/engine/[engine].tsx deleted file mode 100644 index 1135904afc..0000000000 --- a/e2e/next-sandbox/pages/storage/stress/[roomId]/engine/[engine].tsx +++ /dev/null @@ -1,537 +0,0 @@ -import type { Lson, LsonObject } from "@liveblocks/client"; -import { LiveList, LiveMap, LiveObject } from "@liveblocks/client"; -import { createRoomContext } from "@liveblocks/react"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import { useState } from "react"; - -import { Row, styles, useRenderCount } from "../../../../../utils"; -import Button from "../../../../../utils/Button"; -import { createLiveblocksClient } from "../../../../../utils/createClient"; - -const client = createLiveblocksClient({ - authEndpoint: "/api/auth/access-token", -}); - -// Storage starts empty, can grow arbitrarily -type Storage = LsonObject; - -const { - RoomProvider, - useCanRedo, - useCanUndo, - useMutation, - useRedo, - useRoom, - useStatus, - useStorage, - useSyncStatus, - useUndo, -} = createRoomContext(client); - -// JSON.stringify replacer to handle Map objects (LiveMap.toImmutable() returns Map) -function mapReplacer(_key: string, value: unknown): unknown { - if (value instanceof Map) { - return Object.fromEntries(value); - } - return value; -} - -// Deterministic JSON stringify with sorted keys (for consistent hashing) -function stableStringify(obj: unknown): string { - return JSON.stringify(obj, (_key, value: unknown) => { - if (value instanceof Map) { - // Sort map entries by key - const sorted = [...(value as Map).entries()].sort( - ([a], [b]) => a.localeCompare(b) - ); - return Object.fromEntries(sorted); - } - - if (value && typeof value === "object" && !Array.isArray(value)) { - // Sort object keys - const sorted: Record = {}; - for (const k of Object.keys(value).sort()) { - sorted[k] = (value as Record)[k]; - } - return sorted; - } - return value; - }); -} - -function formatSize(bytes: number): string { - if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${bytes} B`; -} - -// Simple hash function for quick comparison -function simpleHash(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32bit integer - } - return Math.abs(hash).toString(16).padStart(8, "0"); -} - -function randomString(length: number): string { - const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; - let result = ""; - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; -} - -function randomInt(max: number): number { - return Math.floor(Math.random() * max); -} - -type LiveNode = LiveObject | LiveList | LiveMap; - -// Create a random tree of given depth/breadth -function createRandomTree(depth: number, breadth: number): LiveNode { - const kind = randomInt(3); // 0=LiveObject, 1=LiveList, 2=LiveMap - - if (kind === 0) { - const init: Record = {}; - for (let i = 0; i < breadth; i++) { - const key = `k_${randomString(6)}`; - if (depth > 0 && Math.random() > 0.3) { - init[key] = createRandomTree(depth - 1, breadth); - } else { - init[key] = randomString(12); - } - } - return new LiveObject(init); - } else if (kind === 1) { - const init: Lson[] = []; - for (let i = 0; i < breadth; i++) { - if (depth > 0 && Math.random() > 0.3) { - init.push(createRandomTree(depth - 1, breadth)); - } else { - init.push(randomString(12)); - } - } - return new LiveList(init); - } else { - const init: [string, Lson][] = []; - for (let i = 0; i < breadth; i++) { - const key = `m_${randomString(6)}`; - if (depth > 0 && Math.random() > 0.3) { - init.push([key, createRandomTree(depth - 1, breadth)]); - } else { - init.push([key, randomString(12)]); - } - } - return new LiveMap(init); - } -} - -// Collect all attachment points in the tree -type AttachmentPoint = - | { type: "object"; node: LiveObject } - | { type: "list"; node: LiveList } - | { type: "map"; node: LiveMap }; - -function collectAttachmentPoints( - root: LiveObject -): AttachmentPoint[] { - const points: AttachmentPoint[] = [{ type: "object", node: root }]; - - function visit(value: unknown) { - if (value instanceof LiveObject) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - points.push({ type: "object", node: value as LiveObject }); - for (const child of Object.values(value.toObject())) { - visit(child); - } - } else if (value instanceof LiveList) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - points.push({ type: "list", node: value as LiveList }); - for (const item of value) { - visit(item); - } - } else if (value instanceof LiveMap) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - points.push({ type: "map", node: value as LiveMap }); - for (const [, v] of value) { - visit(v); - } - } - } - - for (const child of Object.values(root.toObject())) { - visit(child); - } - - return points; -} - -// Size configurations with labels -const SIZES = { - S: { depth: 1, breadth: 2, label: "1×2" }, - M: { depth: 2, breadth: 2, label: "2×2" }, - L: { depth: 2, breadth: 3, label: "2×3" }, - XL: { depth: 3, breadth: 3, label: "3×3" }, - XXL: { depth: 3, breadth: 4, label: "3×4" }, - XXXL: { depth: 4, breadth: 4, label: "4×4" }, -} as const; - -const SHRINK_COUNTS = { - S: { count: 1, label: "1" }, - M: { count: 3, label: "3" }, - L: { count: 8, label: "8" }, - XL: { count: 20, label: "20" }, - XXL: { count: 50, label: "50" }, - XXXL: { count: 100, label: "100" }, -} as const; - -const CHANGE_COUNTS = { - S: { count: 1, label: "1" }, - M: { count: 5, label: "5" }, - L: { count: 15, label: "15" }, - XL: { count: 40, label: "40" }, - XXL: { count: 100, label: "100" }, - XXXL: { count: 250, label: "250" }, -} as const; - -export default function StressTestRoom() { - const router = useRouter(); - const { roomId, engine: engineStr } = router.query; - - // Wait for router to be ready - if (!router.isReady || typeof roomId !== "string") { - return
Loading...
; - } - - const engine = engineStr === "1" ? 1 : engineStr === "2" ? 2 : undefined; - return ( - - - - ); -} - -function Sandbox({ roomId }: { roomId: string }) { - const room = useRoom(); - const renderCount = useRenderCount(); - const status = useStatus(); - const syncStatus = useSyncStatus(); - const immutable = useStorage((root) => root); - const [repeat, setRepeat] = useState(10); - const [showData, setShowData] = useState(false); - const [showHash, setShowHash] = useState(true); - const undo = useUndo(); - const redo = useRedo(); - const canUndo = useCanUndo(); - const canRedo = useCanRedo(); - - const grow = useMutation( - ({ storage }, size: { depth: number; breadth: number }, times: number) => { - const points = collectAttachmentPoints(storage); - for (let t = 0; t < times; t++) { - const point = points[randomInt(points.length)]; - const newTree = createRandomTree(size.depth, size.breadth); - const key = `k_${randomString(6)}`; - - if (point.type === "object") { - point.node.set(key, newTree); - } else if (point.type === "list") { - point.node.push(newTree); - } else { - point.node.set(key, newTree); - } - } - }, - [] - ); - - const shrink = useMutation(({ storage }, count: number, times: number) => { - const points = collectAttachmentPoints(storage); - const totalDeletes = count * times; - const maxAttempts = totalDeletes * 3; // Allow some retries for empty points - - let deleted = 0; - for (let attempt = 0; attempt < maxAttempts && deleted < totalDeletes; attempt++) { - const point = points[randomInt(points.length)]; - - if (point.type === "object") { - const keys = Object.keys(point.node.toObject()); - if (keys.length > 0) { - point.node.delete(keys[randomInt(keys.length)]); - deleted++; - } - } else if (point.type === "list") { - if (point.node.length > 0) { - point.node.delete(randomInt(point.node.length)); - deleted++; - } - } else { - const keys = Array.from(point.node.keys()); - if (keys.length > 0) { - point.node.delete(keys[randomInt(keys.length)]); - deleted++; - } - } - } - }, []); - - const change = useMutation(({ storage }, count: number, times: number) => { - const points = collectAttachmentPoints(storage); - for (let t = 0; t < times; t++) { - for (let i = 0; i < count; i++) { - const point = points[randomInt(points.length)]; - - if (point.type === "object") { - const keys = Object.keys(point.node.toObject()); - if (keys.length > 0 && Math.random() < 0.5) { - // Change existing value to a string - const key = keys[randomInt(keys.length)]; - const val = point.node.get(key); - if (typeof val === "string") { - point.node.set(key, randomString(12)); - } - } else { - // Add a new string key - point.node.set(`k_${randomString(6)}`, randomString(12)); - } - } else if (point.type === "list") { - if (point.node.length > 0 && Math.random() < 0.5) { - const idx = randomInt(point.node.length); - const val = point.node.get(idx); - if (typeof val === "string") { - point.node.set(idx, randomString(12)); - } - } else { - point.node.push(randomString(12)); - } - } else { - const keys = Array.from(point.node.keys()); - if (keys.length > 0 && Math.random() < 0.5) { - const key = keys[randomInt(keys.length)]; - const val = point.node.get(key); - if (typeof val === "string") { - point.node.set(key, randomString(12)); - } - } else { - point.node.set(`m_${randomString(6)}`, randomString(12)); - } - } - } - } - }, []); - - const clear = useMutation(({ storage }) => { - for (const key of Object.keys(storage.toObject())) { - storage.delete(key); - } - }, []); - - if (immutable === undefined) { - return
Loading...
; - } - - return ( -
-

- Home › Storage ›{" "} - - Stress Test - {" "} - › {roomId} -

- -
-
- Repeat: - {[1, 3, 5, 10, 100].map((n) => ( - - ))} -
-
- Grow: - {(Object.keys(SIZES) as (keyof typeof SIZES)[]).map((size) => ( - - ))} -
-
- Shrink: - {(Object.keys(SHRINK_COUNTS) as (keyof typeof SHRINK_COUNTS)[]).map( - (size) => ( - - ) - )} -
-
- Change: - {(Object.keys(CHANGE_COUNTS) as (keyof typeof CHANGE_COUNTS)[]).map( - (size) => ( - - ) - )} -
-
- - - - - - - - -
-
- - - - - - - -
- -

Storage

-
- - -
- {showHash ? ( -
- - {(() => { - const str = stableStringify(immutable); - return `${simpleHash(str)} (${formatSize(str.length)})`; - })()} - -
- ) : null} - {showData ? ( -
-          {JSON.stringify(immutable, mapReplacer, 2)}
-        
- ) : null} -
- ); -} diff --git a/e2e/next-sandbox/pages/storage/stress/[roomId]/index.tsx b/e2e/next-sandbox/pages/storage/stress/[roomId]/index.tsx index a21ab535e4..d382f8cf64 100644 --- a/e2e/next-sandbox/pages/storage/stress/[roomId]/index.tsx +++ b/e2e/next-sandbox/pages/storage/stress/[roomId]/index.tsx @@ -1,157 +1,527 @@ +import type { Lson, LsonObject } from "@liveblocks/client"; +import { LiveList, LiveMap, LiveObject } from "@liveblocks/client"; +import { createRoomContext } from "@liveblocks/react"; import Link from "next/link"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useState } from "react"; -function getEngineFromRoomId(roomId: string): 0 | 1 | 2 { - if (roomId.endsWith("-eng2")) return 2; - if (roomId.endsWith("-eng1")) return 1; - return 0; +import { Row, styles, useRenderCount } from "../../../../utils"; +import Button from "../../../../utils/Button"; +import { createLiveblocksClient } from "../../../../utils/createClient"; + +const client = createLiveblocksClient({ + authEndpoint: "/api/auth/access-token", +}); + +// Storage starts empty, can grow arbitrarily +type Storage = LsonObject; + +const { + RoomProvider, + useCanRedo, + useCanUndo, + useMutation, + useRedo, + useRoom, + useStatus, + useStorage, + useSyncStatus, + useUndo, +} = createRoomContext(client); + +// JSON.stringify replacer to handle Map objects (LiveMap.toImmutable() returns Map) +function mapReplacer(_key: string, value: unknown): unknown { + if (value instanceof Map) { + return Object.fromEntries(value); + } + return value; } -function setEngineInRoomId(roomId: string, engine: 0 | 1 | 2): string { - // Strip existing engine suffix if any - const base = roomId.replace(/-eng[12]$/, ""); - if (engine === 1) return `${base}-eng1`; - if (engine === 2) return `${base}-eng2`; - return base; +// Deterministic JSON stringify with sorted keys (for consistent hashing) +function stableStringify(obj: unknown): string { + return JSON.stringify(obj, (_key, value: unknown) => { + if (value instanceof Map) { + // Sort map entries by key + const sorted = [...(value as Map).entries()].sort( + ([a], [b]) => a.localeCompare(b) + ); + return Object.fromEntries(sorted); + } + + if (value && typeof value === "object" && !Array.isArray(value)) { + // Sort object keys + const sorted: Record = {}; + for (const k of Object.keys(value).sort()) { + sorted[k] = (value as Record)[k]; + } + return sorted; + } + return value; + }); } -export default function StressTestLanding() { - const router = useRouter(); - const { roomId: routeRoomId } = router.query; +function formatSize(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytes} B`; +} + +// Simple hash function for quick comparison +function simpleHash(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash).toString(16).padStart(8, "0"); +} + +function randomString(length: number): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +function randomInt(max: number): number { + return Math.floor(Math.random() * max); +} - const [roomId, setRoomId] = useState(""); +type LiveNode = LiveObject | LiveList | LiveMap; - // Initialize from route param - useEffect(() => { - if (typeof routeRoomId === "string") { - setRoomId(routeRoomId); +// Create a random tree of given depth/breadth +function createRandomTree(depth: number, breadth: number): LiveNode { + const kind = randomInt(3); // 0=LiveObject, 1=LiveList, 2=LiveMap + + if (kind === 0) { + const init: Record = {}; + for (let i = 0; i < breadth; i++) { + const key = `k_${randomString(6)}`; + if (depth > 0 && Math.random() > 0.3) { + init[key] = createRandomTree(depth - 1, breadth); + } else { + init[key] = randomString(12); + } + } + return new LiveObject(init); + } else if (kind === 1) { + const init: Lson[] = []; + for (let i = 0; i < breadth; i++) { + if (depth > 0 && Math.random() > 0.3) { + init.push(createRandomTree(depth - 1, breadth)); + } else { + init.push(randomString(12)); + } + } + return new LiveList(init); + } else { + const init: [string, Lson][] = []; + for (let i = 0; i < breadth; i++) { + const key = `m_${randomString(6)}`; + if (depth > 0 && Math.random() > 0.3) { + init.push([key, createRandomTree(depth - 1, breadth)]); + } else { + init.push([key, randomString(12)]); + } + } + return new LiveMap(init); + } +} + +// Collect all attachment points in the tree +type AttachmentPoint = + | { type: "object"; node: LiveObject } + | { type: "list"; node: LiveList } + | { type: "map"; node: LiveMap }; + +function collectAttachmentPoints( + root: LiveObject +): AttachmentPoint[] { + const points: AttachmentPoint[] = [{ type: "object", node: root }]; + + function visit(value: unknown) { + if (value instanceof LiveObject) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + points.push({ type: "object", node: value as LiveObject }); + for (const child of Object.values(value.toObject())) { + visit(child); + } + } else if (value instanceof LiveList) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + points.push({ type: "list", node: value as LiveList }); + for (const item of value) { + visit(item); + } + } else if (value instanceof LiveMap) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + points.push({ type: "map", node: value as LiveMap }); + for (const [, v] of value) { + visit(v); + } } - }, [routeRoomId]); + } + + for (const child of Object.values(root.toObject())) { + visit(child); + } + + return points; +} + +// Size configurations with labels +const SIZES = { + S: { depth: 1, breadth: 2, label: "1×2" }, + M: { depth: 2, breadth: 2, label: "2×2" }, + L: { depth: 2, breadth: 3, label: "2×3" }, + XL: { depth: 3, breadth: 3, label: "3×3" }, + XXL: { depth: 3, breadth: 4, label: "3×4" }, + XXXL: { depth: 4, breadth: 4, label: "4×4" }, +} as const; + +const SHRINK_COUNTS = { + S: { count: 1, label: "1" }, + M: { count: 3, label: "3" }, + L: { count: 8, label: "8" }, + XL: { count: 20, label: "20" }, + XXL: { count: 50, label: "50" }, + XXXL: { count: 100, label: "100" }, +} as const; + +const CHANGE_COUNTS = { + S: { count: 1, label: "1" }, + M: { count: 5, label: "5" }, + L: { count: 15, label: "15" }, + XL: { count: 40, label: "40" }, + XXL: { count: 100, label: "100" }, + XXXL: { count: 250, label: "250" }, +} as const; + +export default function StressTestRoom() { + const router = useRouter(); + const { roomId } = router.query; // Wait for router to be ready - if (!router.isReady || typeof routeRoomId !== "string") { + if (!router.isReady || typeof roomId !== "string") { return
Loading...
; } - const currentEngine = getEngineFromRoomId(roomId); + return ( + + + + ); +} + +function Sandbox({ roomId }: { roomId: string }) { + const room = useRoom(); + const renderCount = useRenderCount(); + const status = useStatus(); + const syncStatus = useSyncStatus(); + const immutable = useStorage((root) => root); + const [repeat, setRepeat] = useState(10); + const [showData, setShowData] = useState(false); + const [showHash, setShowHash] = useState(true); + const undo = useUndo(); + const redo = useRedo(); + const canUndo = useCanUndo(); + const canRedo = useCanRedo(); + + const grow = useMutation( + ({ storage }, size: { depth: number; breadth: number }, times: number) => { + const points = collectAttachmentPoints(storage); + for (let t = 0; t < times; t++) { + const point = points[randomInt(points.length)]; + const newTree = createRandomTree(size.depth, size.breadth); + const key = `k_${randomString(6)}`; + + if (point.type === "object") { + point.node.set(key, newTree); + } else if (point.type === "list") { + point.node.push(newTree); + } else { + point.node.set(key, newTree); + } + } + }, + [] + ); - const handleEngineClick = (engine: 0 | 1 | 2) => { - const newRoomId = setEngineInRoomId(roomId, engine); - setRoomId(newRoomId); - // Update URL to reflect new room ID - void router.replace(`/storage/stress/${encodeURIComponent(newRoomId)}`, undefined, { shallow: true }); - }; + const shrink = useMutation(({ storage }, count: number, times: number) => { + const points = collectAttachmentPoints(storage); + const totalDeletes = count * times; + const maxAttempts = totalDeletes * 3; // Allow some retries for empty points - const handleRoomIdChange = (newRoomId: string) => { - setRoomId(newRoomId); - }; + let deleted = 0; + for (let attempt = 0; attempt < maxAttempts && deleted < totalDeletes; attempt++) { + const point = points[randomInt(points.length)]; - const handleRoomIdBlur = () => { - // Update URL when input loses focus - if (roomId.trim() && roomId !== routeRoomId) { - void router.replace(`/storage/stress/${encodeURIComponent(roomId.trim())}`, undefined, { shallow: true }); + if (point.type === "object") { + const keys = Object.keys(point.node.toObject()); + if (keys.length > 0) { + point.node.delete(keys[randomInt(keys.length)]); + deleted++; + } + } else if (point.type === "list") { + if (point.node.length > 0) { + point.node.delete(randomInt(point.node.length)); + deleted++; + } + } else { + const keys = Array.from(point.node.keys()); + if (keys.length > 0) { + point.node.delete(keys[randomInt(keys.length)]); + deleted++; + } + } } - }; + }, []); - const handleConnect = () => { - if (roomId.trim()) { - const engine = getEngineFromRoomId(roomId.trim()); - void router.push(`/storage/stress/${encodeURIComponent(roomId.trim())}/engine/${engine}`); + const change = useMutation(({ storage }, count: number, times: number) => { + const points = collectAttachmentPoints(storage); + for (let t = 0; t < times; t++) { + for (let i = 0; i < count; i++) { + const point = points[randomInt(points.length)]; + + if (point.type === "object") { + const keys = Object.keys(point.node.toObject()); + if (keys.length > 0 && Math.random() < 0.5) { + // Change existing value to a string + const key = keys[randomInt(keys.length)]; + const val = point.node.get(key); + if (typeof val === "string") { + point.node.set(key, randomString(12)); + } + } else { + // Add a new string key + point.node.set(`k_${randomString(6)}`, randomString(12)); + } + } else if (point.type === "list") { + if (point.node.length > 0 && Math.random() < 0.5) { + const idx = randomInt(point.node.length); + const val = point.node.get(idx); + if (typeof val === "string") { + point.node.set(idx, randomString(12)); + } + } else { + point.node.push(randomString(12)); + } + } else { + const keys = Array.from(point.node.keys()); + if (keys.length > 0 && Math.random() < 0.5) { + const key = keys[randomInt(keys.length)]; + const val = point.node.get(key); + if (typeof val === "string") { + point.node.set(key, randomString(12)); + } + } else { + point.node.set(`m_${randomString(6)}`, randomString(12)); + } + } + } } - }; + }, []); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - handleConnect(); + const clear = useMutation(({ storage }) => { + for (const key of Object.keys(storage.toObject())) { + storage.delete(key); } - }; + }, []); + + if (immutable === undefined) { + return
Loading...
; + } return (

- HomeStorage › Stress Test + HomeStorage › Stress + Test › {roomId}

-
- Engine: - - - +
+ Repeat: + {[1, 3, 5, 10, 100].map((n) => ( + + ))}
- -
- - handleRoomIdChange(e.target.value)} - onBlur={handleRoomIdBlur} - onKeyDown={handleKeyDown} - style={{ padding: "4px 8px", width: "250px", fontFamily: "monospace" }} - /> +
+ Grow: + {(Object.keys(SIZES) as (keyof typeof SIZES)[]).map((size) => ( + + ))} +
+
+ Shrink: + {(Object.keys(SHRINK_COUNTS) as (keyof typeof SHRINK_COUNTS)[]).map( + (size) => ( + + ) + )} +
+
+ Change: + {(Object.keys(CHANGE_COUNTS) as (keyof typeof CHANGE_COUNTS)[]).map( + (size) => ( + + ) + )}
+
+ + + + + +
+
-
- + {(() => { + const str = stableStringify(immutable); + return `${simpleHash(str)} (${formatSize(str.length)})`; + })()} +
-
+ ) : null} + {showData ? ( +
+          {JSON.stringify(immutable, mapReplacer, 2)}
+        
+ ) : null}
); } diff --git a/packages/liveblocks-core/src/client.ts b/packages/liveblocks-core/src/client.ts index 3090bea9a9..0d766d97d3 100644 --- a/packages/liveblocks-core/src/client.ts +++ b/packages/liveblocks-core/src/client.ts @@ -129,9 +129,8 @@ export type EnterOptions

= autoConnect?: boolean; /** - * Preferred storage engine version to use when creating the room. Only - * takes effect if the room doesn't exist yet. Version 2 can support larger - * documents, is more performant, and will become the default in the future. + * @deprecated This flag no longer has any effect and will be removed in + * a future version. All rooms now use the v2 storage engine by default. */ engine?: 1 | 2; } @@ -514,10 +513,9 @@ export type ClientOptions = { backgroundKeepAliveTimeout?: number; // in milliseconds polyfills?: Polyfills; /** - * @deprecated For new rooms, use `engine: 2` instead. Rooms on the v2 - * Storage engine have native support for streaming. This flag will be - * removed in a future version, but will continue to work for existing engine - * v1 rooms for now. + * @deprecated All rooms will be migrated to the v2 storage engine in the + * future, which has native support for streaming. After that migration, this + * flag will no longer have any effect and will be removed in a future version. */ unstable_streamData?: boolean; /** @@ -777,8 +775,7 @@ export function createClient( createSocket: makeCreateSocketDelegateForRoom( roomId, baseUrl, - clientOptions.polyfills?.WebSocket, - options.engine + clientOptions.polyfills?.WebSocket ), authenticate: makeAuthDelegateForRoom(roomId, authManager), }, diff --git a/packages/liveblocks-core/src/room.ts b/packages/liveblocks-core/src/room.ts index 53d71fc9af..f8b1a249d7 100644 --- a/packages/liveblocks-core/src/room.ts +++ b/packages/liveblocks-core/src/room.ts @@ -1274,10 +1274,9 @@ export type RoomConfig = { backgroundKeepAliveTimeout?: number; /** - * @deprecated For new rooms, use `engine: 2` instead. Rooms on the v2 - * Storage engine have native support for streaming. This flag will be - * removed in a future version, but will continue to work for existing engine - * v1 rooms for now. + * @deprecated All rooms will be migrated to the v2 storage engine in the + * future, which has native support for streaming. After that migration, this + * flag will no longer have any effect and will be removed in a future version. */ unstable_streamData?: boolean; @@ -3355,8 +3354,7 @@ export function makeAuthDelegateForRoom( export function makeCreateSocketDelegateForRoom( roomId: string, baseUrl: string, - WebSocketPolyfill?: IWebSocket, - engine?: 1 | 2 + WebSocketPolyfill?: IWebSocket ) { return (authValue: AuthValue): IWebSocketInstance => { const ws: IWebSocket | undefined = @@ -3381,9 +3379,6 @@ export function makeCreateSocketDelegateForRoom( return assertNever(authValue, "Unhandled case"); } url.searchParams.set("version", PKG_VERSION || "dev"); - if (engine !== undefined) { - url.searchParams.set("e", String(engine)); - } return new ws(url.toString()); }; } diff --git a/packages/liveblocks-node/src/__tests__/client.test.ts b/packages/liveblocks-node/src/__tests__/client.test.ts index a6eef6bf11..9af730883d 100644 --- a/packages/liveblocks-node/src/__tests__/client.test.ts +++ b/packages/liveblocks-node/src/__tests__/client.test.ts @@ -563,7 +563,6 @@ describe("client", () => { usersAccesses: undefined, organizationId: tenantId, metadata: undefined, - engine: undefined, }); }); @@ -621,11 +620,10 @@ describe("client", () => { usersAccesses: undefined, organizationId, metadata: undefined, - engine: undefined, }); }); - test("should pass engine to the request when createRoom is called with engine", async () => { + test("should not send engine in the request even when provided", async () => { const roomId = "test-room"; const createRoomParams = { defaultAccesses: ["room:write"] as ["room:write"], @@ -649,38 +647,7 @@ describe("client", () => { defaultAccesses: ["room:write"], groupsAccesses: undefined, usersAccesses: undefined, - tenantId: undefined, metadata: undefined, - engine: 2, - }); - }); - - test("should not include engine in the request when createRoom is called without engine", async () => { - const roomId = "test-room"; - const createRoomParams = { - defaultAccesses: ["room:write"] as ["room:write"], - }; - - let capturedRequestData: unknown = null; - - server.use( - http.post(`${DEFAULT_BASE_URL}/v2/rooms`, async ({ request }) => { - capturedRequestData = await request.json(); - return HttpResponse.json(room, { status: 200 }); - }) - ); - - const client = new Liveblocks({ secret: "sk_xxx" }); - await client.createRoom(roomId, createRoomParams); - - expect(capturedRequestData).toEqual({ - id: roomId, - defaultAccesses: ["room:write"], - groupsAccesses: undefined, - usersAccesses: undefined, - tenantId: undefined, - metadata: undefined, - engine: undefined, }); }); }); diff --git a/packages/liveblocks-node/src/client.ts b/packages/liveblocks-node/src/client.ts index 959c25713d..34dbfc942e 100644 --- a/packages/liveblocks-node/src/client.ts +++ b/packages/liveblocks-node/src/client.ts @@ -466,9 +466,8 @@ export type CreateRoomOptions = { organizationId?: string; /** - * Preferred storage engine version to use when creating the room. Only takes - * effect if the room doesn't exist yet. Version 2 can support larger - * documents, is more performant, and will become the default in the future. + * @deprecated This flag no longer has any effect and will be removed in + * a future version. All rooms now use the v2 storage engine by default. */ engine?: 1 | 2; }; @@ -1065,7 +1064,6 @@ export class Liveblocks { metadata, tenantId, organizationId, - engine, } = params; const body: { @@ -1075,14 +1073,12 @@ export class Liveblocks { usersAccesses?: RoomAccesses; metadata?: RoomMetadata; organizationId?: string; - engine?: 1 | 2; } = { id: roomId, defaultAccesses, groupsAccesses, usersAccesses, metadata, - engine, }; if (organizationId !== undefined) { diff --git a/packages/liveblocks-react/src/types/index.ts b/packages/liveblocks-react/src/types/index.ts index 2aba3f615b..d56ab6bd71 100644 --- a/packages/liveblocks-react/src/types/index.ts +++ b/packages/liveblocks-react/src/types/index.ts @@ -345,9 +345,8 @@ export type RoomProviderProps

= autoConnect?: boolean; /** - * Preferred storage engine version to use when creating the room. Only - * takes effect if the room doesn't exist yet. Version 2 can support larger - * documents, is more performant, and will become the default in the future. + * @deprecated This flag no longer has any effect and will be removed in + * a future version. All rooms now use the v2 storage engine by default. */ engine?: 1 | 2; } From 047c0c49f83fb95f29931acf0840423ae6aa558a Mon Sep 17 00:00:00 2001 From: Chris Nicholas Date: Wed, 11 Mar 2026 16:26:26 +0000 Subject: [PATCH 2/2] Mentioning token caching (#3172) --- docs/pages/api-reference/liveblocks-node.mdx | 29 +++++++++++++++---- docs/pages/api-reference/liveblocks-react.mdx | 14 +++++++-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/pages/api-reference/liveblocks-node.mdx b/docs/pages/api-reference/liveblocks-node.mdx index 9034b64275..71603fc661 100644 --- a/docs/pages/api-reference/liveblocks-node.mdx +++ b/docs/pages/api-reference/liveblocks-node.mdx @@ -78,6 +78,15 @@ const { body, status } = await liveblocks.identifyUser( ); ``` + + +Never cache your access token authentication endpoint, as your client will not +function correctly. The Liveblocks client will cache results for you, only +making requests to the endpoint if necessary, such as when the token has +expired. + + + ##### Granting ID token permissions You can pass additional options to `identifyUser`, enabling you to create @@ -230,8 +239,8 @@ assigning group permissions. These are arbitrary identifiers that make sense to your app, and that you can refer to in the [Permissions REST API][] when assigning group permissions. -`organizationId` (optional) is the organization for this user, will be set to `default` if -not provided. +`organizationId` (optional) is the organization for this user, will be set to +`default` if not provided. `userInfo` (optional) is any custom JSON value, which you can use to attach static metadata to this user’s session. This will be publicly visible to all @@ -323,6 +332,15 @@ const session = liveblocks.prepareSession( ); ``` + + +Never cache your access token authentication endpoint, as your client will not +function correctly. The Liveblocks client will cache results for you, only +making requests to the endpoint if necessary, such as when the token has +expired. + + + ##### Granting access token permissions Using `session.allow()`, you can grant full or read-only permissions to the user @@ -353,10 +371,11 @@ session.allow("design-room:*", session.FULL_ACCESS); Additionally, you can pass additional options to `prepareSession`, enabling you -to create complex permissions using [organizations](/docs/authentication/organizations) and +to create complex permissions using +[organizations](/docs/authentication/organizations) and [accesses](/docs/authentication/access-tokens/permissions). For example, this -user can only see resources in the `acme-corp` organization, and they're part of a -`marketing` group within it. +user can only see resources in the `acme-corp` organization, and they're part of +a `marketing` group within it. ```ts const session = liveblocks.prepareSession( diff --git a/docs/pages/api-reference/liveblocks-react.mdx b/docs/pages/api-reference/liveblocks-react.mdx index 23f6183aa2..3f0d94d139 100644 --- a/docs/pages/api-reference/liveblocks-react.mdx +++ b/docs/pages/api-reference/liveblocks-react.mdx @@ -332,8 +332,10 @@ function App() { > The URL of your back end’s [authentication endpoint](/docs/authentication) as a string, or an async callback function that returns a Liveblocks token - result. Either `authEndpoint` or `publicApiKey` are required. Learn more - about [using a URL string](#LiveblocksProviderAuthEndpoint) and [using a + result. The result is cached by the Liveblocks client, and called fresh only + when necessary, so never cache it yourself. Either `authEndpoint` or + `publicApiKey` are required. Learn more about [using a URL + string](#LiveblocksProviderAuthEndpoint) and [using a callback](#LiveblocksProviderCallback). @@ -475,6 +477,14 @@ function App() { } ``` + + +Never cache your authentication endpoint, as your client will not function +correctly. The Liveblocks client will cache results for you, only making +requests to the endpoint if necessary, such as when the token has expired. + + + #### LiveblocksProvider with auth endpoint callback [#LiveblocksProviderCallback] If you need to add additional headers or use your own function to call your