diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 2a1a3e7b5d3..9ad34d14ecc 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -91,8 +91,94 @@ commit_to_git () { ) ) } +# A "final" release is X.Y.Z with no -prerelease suffix. Pre-releases like +# 3.19.4-rc2 are intentionally excluded from CHANGELOG heading insertion. +is_final_release () { + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +} + +# True if the given CHANGELOG already has a "## vX.Y.Z" heading for this version. +changelog_has_heading () { + grep -qE "^## v${VERSION//./\\.}( .*)?\$" "$1" +} + +# True if a fresh "## vX.Y.Z" heading is due to be inserted into this CHANGELOG. +changelog_needs_heading () { + is_final_release && [ -f "$1" ] && ! changelog_has_heading "$1" +} + +# Classify the "## vNEXT" section of a CHANGELOG: prints "nonempty", "empty" +# (immediately followed by another "## " heading or only blank lines), or +# "missing" (no vNEXT section at all). +vnext_state () { + awk ' + /^## vNEXT( .*)?$/ { seen = 1; next } + seen && /^## / { print "empty"; found = 1; exit } + seen && /[^[:space:]]/ { print "nonempty"; found = 1; exit } + END { if (!found) print (seen ? "empty" : "missing") } + ' "$1" +} + +# Fail fast (before any version bump) if a heading is due for this release but +# there are no entries to release under "## vNEXT". A skipped heading +# (pre-release, or already present) needs no entries and is left alone. +assert_changelog_ready () { + CHANGELOG="$1" + changelog_needs_heading "$CHANGELOG" || return 0 + case "$( vnext_state "$CHANGELOG" )" in + nonempty) ;; + empty) + err "ERROR: No changelog entries under '## vNEXT' in $CHANGELOG." + err "Add release notes there before releasing v$VERSION." + exit 2 ;; + missing) + err "ERROR: No '## vNEXT' section found in $CHANGELOG." + err "Cannot insert a heading for v$VERSION." + exit 2 ;; + esac +} + +# Insert a "## vX.Y.Z" heading right below the "vNEXT" section, claiming all +# accumulated entries for this release. No-op for pre-releases and for versions +# that already have a heading. +inject_changelog_heading () { + CHANGELOG="$1" + + if ! is_final_release; then + echo "==> Skipping CHANGELOG heading for pre-release $VERSION" + return + fi + if changelog_has_heading "$CHANGELOG"; then + echo "==> CHANGELOG already has a heading for v$VERSION, leaving as-is" + return + fi + + echo "==> Adding CHANGELOG heading for v$VERSION" + tmp="$(mktemp)" + if awk -v ver="$VERSION" ' + !done && /^## vNEXT( .*)?$/ { + print + print "" + print "## v" ver + done = 1 + next + } + { print } + END { exit (done ? 0 : 3) } + ' "$CHANGELOG" > "$tmp"; then + mv "$tmp" "$CHANGELOG" + else + rm -f "$tmp" + err "WARNING: Could not find a '## vNEXT' section in" + err "$CHANGELOG; skipping CHANGELOG heading insertion." + fi +} + check_is_valid_version "$VERSION" +# Fail fast if a heading is due for this release but vNEXT has no entries +assert_changelog_ready "$ROOT/CHANGELOG.md" + # Run a fresh install to ensure the lock file isn't outdated before continuing pnpm install --no-frozen-lockfile git is-clean -v @@ -104,4 +190,7 @@ done # Update pnpm-lock.yaml with newly bumped versions pnpm install --no-frozen-lockfile -commit_to_git "${COMMIT_MESSAGE}${VERSION}" "pnpm-lock.yaml" "packages/" "tools/" +# Add a CHANGELOG heading for this release if one doesn't exist yet +inject_changelog_heading "$ROOT/CHANGELOG.md" + +commit_to_git "${COMMIT_MESSAGE}${VERSION}" "pnpm-lock.yaml" "packages/" "tools/" "CHANGELOG.md" diff --git a/.github/workflows/publish-docker-images.yml b/.github/workflows/publish-docker-images.yml index 1c787fa2c03..61e68711055 100644 --- a/.github/workflows/publish-docker-images.yml +++ b/.github/workflows/publish-docker-images.yml @@ -1,22 +1,8 @@ -name: Publish Docker images +name: Publish Docker image for Liveblocks dev server +# Builds and publishes the Liveblocks dev-server Docker image. Run manually after +# the `liveblocks` CLI is published to npm, passing the published CLI version. on: - push: - tags: - - "v*" - branches: - - main - paths: - - "tools/liveblocks-cli/**" - - "packages/liveblocks-server/**" - - ".github/workflows/publish-docker-images.yml" - - ".github/workflows/docker-image.yml" - pull_request: - paths: - - "tools/liveblocks-cli/**" - - "packages/liveblocks-server/**" - - ".github/workflows/publish-docker-images.yml" - - ".github/workflows/docker-image.yml" workflow_dispatch: inputs: version: @@ -25,49 +11,12 @@ on: description: "CLI package version to build Docker images for (e.g. 1.0.6, without v prefix). Images are tagged as a release with this version." jobs: - version: - runs-on: ubuntu-latest - outputs: - version: ${{ steps.v.outputs.version }} - is_release: ${{ steps.v.outputs.is_release }} - # Non-empty only for workflow_dispatch triggers. Passed to docker-image.yml - # so it can apply explicit version tags (type=raw) since there is no git tag - # for docker/metadata-action's type=semver to derive versions from. - release_version: ${{ steps.v.outputs.release_version }} - steps: - - name: Extract version - id: v - run: | - if [[ -n "${{ inputs.version }}" ]]; then - # External trigger with explicit version (e.g. from liveblocks-backend - # publish workflow). There is no git tag, so we pass the version - # explicitly for docker-image.yml to use as raw tags. - VERSION="${{ inputs.version }}" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "is_release=true" >> "$GITHUB_OUTPUT" - echo "release_version=${VERSION}" >> "$GITHUB_OUTPUT" - elif [[ "${{ github.event_name }}" == "push" && "${{ github.ref_type }}" == "tag" ]]; then - # Tag push (e.g. v1.0.6). docker/metadata-action derives semver tags - # from the git tag automatically, so release_version is left empty. - VERSION="${GITHUB_REF_NAME#v}" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "is_release=true" >> "$GITHUB_OUTPUT" - echo "release_version=" >> "$GITHUB_OUTPUT" - else - # Non-release build (branch push, PR). No version tags applied. - VERSION=$(npm view liveblocks version) - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "is_release=false" >> "$GITHUB_OUTPUT" - echo "release_version=" >> "$GITHUB_OUTPUT" - fi - publish: - needs: version uses: ./.github/workflows/docker-image.yml with: image: liveblocks/dev-server - is_release: ${{ needs.version.outputs.is_release }} - release_version: ${{ needs.version.outputs.release_version }} + is_release: "true" + release_version: ${{ inputs.version }} build-args: | - CLI_VERSION=${{ needs.version.outputs.version }} - VERSION=${{ needs.version.outputs.version }} + CLI_VERSION=${{ inputs.version }} + VERSION=${{ inputs.version }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4ccf0caa9cb..cc84f181f26 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,13 +34,6 @@ jobs: needs: check_for_code_changes runs-on: ubuntu-latest - # Run the Liveblocks dev server on port 1154 for all tests - services: - liveblocks: - image: ghcr.io/liveblocks/dev-server:main - ports: - - 1154:1153 - strategy: matrix: pkg: @@ -112,7 +105,7 @@ jobs: - name: Run unit tests if: needs.check_for_code_changes.outputs.changes == 'true' - run: pnpm run test:ci --filter ${{ matrix.pkg }} -- --coverage + run: pnpm run test:ci --filter ${{ matrix.pkg }} - name: Run type tests if: needs.check_for_code_changes.outputs.changes == 'true' @@ -169,7 +162,7 @@ jobs: run: pnpm exec playwright install chromium --no-shell --with-deps working-directory: e2e/next-sandbox - - name: Run Playwright tests + - name: Run Playwright tests (against real production backend) if: needs.check_for_code_changes.outputs.changes == 'true' run: pnpm run test --filter @liveblocks/next-sandbox @@ -219,7 +212,7 @@ jobs: run: pnpm exec playwright install chromium --no-shell --with-deps working-directory: e2e/next-ai-kitchen-sink - - name: Run AI Playwright tests + - name: Run AI Playwright tests (against real production backend) if: needs.check_for_code_changes.outputs.changes == 'true' run: pnpm run test --filter @liveblocks/next-ai-kitchen-sink @@ -265,7 +258,7 @@ jobs: if: needs.check_for_code_changes.outputs.changes == 'true' run: pnpm install --frozen-lockfile - - name: Run node-sandbox tests + - name: Run node-sandbox tests (against real production backend) if: needs.check_for_code_changes.outputs.changes == 'true' run: pnpm run test --filter node-sandbox @@ -300,7 +293,7 @@ jobs: if: needs.check_for_code_changes.outputs.changes == 'true' run: pnpm install --frozen-lockfile - - name: Run e2e client specs tests + - name: Run e2e client specs tests (against local dev server) if: needs.check_for_code_changes.outputs.changes == 'true' run: pnpm run test:e2e working-directory: packages/liveblocks-core diff --git a/CHANGELOG.md b/CHANGELOG.md index a470d7a7330..687525708fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ ## vNEXT (not yet released) +## v3.19.4 + +### `@liveblocks/client` + +- Fix `LiveList.push()` so concurrent pushes from multiple clients no longer + settle out of order. +- Fix a bug where a `LiveObject` key deleted while a client was offline would + reappear on reconnect, preventing the two clients from reconverging. +- Fix a bug where reconnecting would emit too many update notifications for + `LiveObject` keys whose values had not actually changed. +- Fix a bug where deleting a nested live value from a `LiveObject` omitted the + removed value (`deletedItem`) from the change notification. + ## v3.19.3 ### `@liveblocks/client` diff --git a/e2e/next-sandbox/test/client.test.ts b/e2e/next-sandbox/test/client.test.ts index f249d9a2fd2..4eb37ccea1c 100644 --- a/e2e/next-sandbox/test/client.test.ts +++ b/e2e/next-sandbox/test/client.test.ts @@ -62,14 +62,19 @@ test.describe("Client logout", () => { await test.step("Logout and verify reconnection", async () => { await page.click("#logout"); - await waitForJson(page, "#socketStatus_1", "connected"); - await waitForJson(page, "#socketStatus_2", "connected"); - await waitForJson(page, "#socketStatus_3", "connected"); + + // logout() clears the auth cache and reconnects every room, so each must + // re-auth before connecting. That path is bounded by AUTH_TIMEOUT (10s), + // well above waitForJson's 5s default, so widen the window past it. + const timeout = 15_000; + await waitForJson(page, "#socketStatus_1", "connected", { timeout }); + await waitForJson(page, "#socketStatus_2", "connected", { timeout }); + await waitForJson(page, "#socketStatus_3", "connected", { timeout }); // All three rooms get re-connected (and thus increment their connection ID) - await waitForJson(page, "#connectionId_1", connId1 + 1); - await waitForJson(page, "#connectionId_2", connId2 + 1); - await waitForJson(page, "#connectionId_3", connId3 + 1); + await waitForJson(page, "#connectionId_1", connId1 + 1, { timeout }); + await waitForJson(page, "#connectionId_2", connId2 + 1, { timeout }); + await waitForJson(page, "#connectionId_3", connId3 + 1, { timeout }); }); }); diff --git a/e2e/next-sandbox/test/full-room.test.ts b/e2e/next-sandbox/test/full-room.test.ts index 2cd5ae9a3a8..009cf6084ae 100644 --- a/e2e/next-sandbox/test/full-room.test.ts +++ b/e2e/next-sandbox/test/full-room.test.ts @@ -9,6 +9,11 @@ import { genRoomId, preparePages, waitForJson } from "./utils"; const TEST_URL = "http://localhost:3007/offline/"; +// Launching 10 full Chromium instances starves CPU/network, so the first +// WebSocket connect legitimately takes longer than the 5s default. Give the +// connection waits extra headroom for this load scenario. +const LOAD_TIMEOUT = 15_000; + // These load tests sometimes fail on CI, possibly because they're too // resource-intensive for the limited GitHub Actions runners (too many tabs // open, or too slow, hitting the timeout limit). So for now, we'll just skip @@ -36,8 +41,12 @@ test.describe("Room completely full", () => { const batch = await preparePages(url, { n: 5 }); // ...of 5 windows each batches.push(batch); pagesToClose.push(...batch); - await waitForJson(batch, "#socketStatus", "connected"); - await waitForJson(pagesToClose, "#numOthers", pagesToClose.length - 1); + await waitForJson(batch, "#socketStatus", "connected", { + timeout: LOAD_TIMEOUT, + }); + await waitForJson(pagesToClose, "#numOthers", pagesToClose.length - 1, { + timeout: LOAD_TIMEOUT, + }); } // Close the first batch of 5 windows @@ -60,8 +69,12 @@ test.describe("Room completely full", () => { const batch = await preparePages(url, { n: 5 }); // ...of 5 windows each batches.push(batch); pagesToClose.push(...batch); - await waitForJson(batch, "#socketStatus", "connected"); - await waitForJson(pagesToClose, "#numOthers", pagesToClose.length - 1); + await waitForJson(batch, "#socketStatus", "connected", { + timeout: LOAD_TIMEOUT, + }); + await waitForJson(pagesToClose, "#numOthers", pagesToClose.length - 1, { + timeout: LOAD_TIMEOUT, + }); } // Try to open one more... this will FAIL, because the room can hold max 20 connections diff --git a/e2e/next-sandbox/test/multi.test.ts b/e2e/next-sandbox/test/multi.test.ts index c00244fc290..300690a9e60 100644 --- a/e2e/next-sandbox/test/multi.test.ts +++ b/e2e/next-sandbox/test/multi.test.ts @@ -280,15 +280,17 @@ test.describe("Multiple rooms (index)", () => { 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); + // Wait until storage has loaded and initialRoom_1 reflects roomA. A + // "connected" socket does not imply storage has finished loading, so a + // one-shot read here races and can still see null. + await waitForJson(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); + // Wait until the room switch settles and initialRoom_1 reflects roomB. + await waitForJson(page, "#initialRoom_1", roomB); // Unmount await page.click("#unmount_1"); @@ -601,15 +603,17 @@ test.describe("Multiple rooms (global augmentation)", () => { 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); + // Wait until storage has loaded and initialRoom_1 reflects roomA. A + // "connected" socket does not imply storage has finished loading, so a + // one-shot read here races and can still see null. + await waitForJson(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); + // Wait until the room switch settles and initialRoom_1 reflects roomB. + await waitForJson(page, "#initialRoom_1", roomB); // Unmount await page.click("#unmount_1"); diff --git a/e2e/next-sandbox/test/offline.test.ts b/e2e/next-sandbox/test/offline.test.ts index 8e89ac0b811..99dc456c3e0 100644 --- a/e2e/next-sandbox/test/offline.test.ts +++ b/e2e/next-sandbox/test/offline.test.ts @@ -162,7 +162,9 @@ test.describe("Offline", () => { await page1.click("#push"); await page1.click("#close-with-unexpected-condition"); - await expectJson(page1, "#socketStatus", "reconnecting"); + // The transition to "reconnecting" is driven by the async onclose event, + // so wait for it rather than reading the status one-shot. + await waitForJson(page1, "#socketStatus", "reconnecting"); await page1.click("#push"); await page1.click("#push"); @@ -180,7 +182,9 @@ test.describe("Offline", () => { await page1.click("#push"); await page1.click("#close-with-abnormal-reason"); - await expectJson(page1, "#socketStatus", "reconnecting"); + // The transition to "reconnecting" is driven by the async onclose event, + // so wait for it rather than reading the status one-shot. + await waitForJson(page1, "#socketStatus", "reconnecting"); await page1.click("#push"); await page1.click("#push"); @@ -198,7 +202,9 @@ test.describe("Offline", () => { await page1.click("#push"); await page1.click("#close-with-token-expired"); - await expectJson(page1, "#socketStatus", "reconnecting"); + // The transition to "reconnecting" is driven by the async onclose event, + // so wait for it rather than reading the status one-shot. + await waitForJson(page1, "#socketStatus", "reconnecting"); await page1.click("#push"); await page1.click("#push"); diff --git a/e2e/next-sandbox/test/presence/index.test.ts b/e2e/next-sandbox/test/presence/index.test.ts index 2f5fea7293b..44e7e6dabc5 100644 --- a/e2e/next-sandbox/test/presence/index.test.ts +++ b/e2e/next-sandbox/test/presence/index.test.ts @@ -92,7 +92,10 @@ test.describe("Presence", () => { await page1.click("#set-bar"); await waitForJson(page2, "#numOthers", 1); - await expectJson(page2, "#theirPresence", { + // numOthers becoming 1 only means the peer is known; its presence + // payload arrives in a separate message and can still be {} here, so + // wait until theirPresence settles instead of reading it one-shot. + await waitForJson(page2, "#theirPresence", { bar: "hey", qux: 1337, }); @@ -139,7 +142,9 @@ test.describe("Presence (w/ specific window timing)", () => { const page2 = await preparePage(url + BG_COLOR_2); await waitForJson([page1, page2], "#numOthers", 1); - await expectJson(page2, "#theirPresence", { foo: 1 }); + // The peer's presence propagates separately from the join, so wait until + // theirPresence settles rather than reading it one-shot. + await waitForJson(page2, "#theirPresence", { foo: 1 }); await page1.close(); await page2.close(); diff --git a/e2e/next-sandbox/test/presence/with-suspense.test.ts b/e2e/next-sandbox/test/presence/with-suspense.test.ts index 1a298df5828..a7d01d7ba42 100644 --- a/e2e/next-sandbox/test/presence/with-suspense.test.ts +++ b/e2e/next-sandbox/test/presence/with-suspense.test.ts @@ -92,7 +92,10 @@ test.describe("Presence w/ Suspense", () => { await page1.click("#set-bar"); await waitForJson(page2, "#numOthers", 1); - await expectJson(page2, "#theirPresence", { + // numOthers becoming 1 only means the peer is known; its presence + // payload arrives in a separate message and can still be {} here, so + // wait until theirPresence settles instead of reading it one-shot. + await waitForJson(page2, "#theirPresence", { bar: "hey", qux: 1337, }); @@ -140,7 +143,9 @@ test.describe("Presence w/ Suspense + specific window timing", () => { const page2 = await preparePage(url + BG_COLOR_2); await waitForJson([page1, page2], "#numOthers", 1); - await expectJson(page2, "#theirPresence", { foo: 1 }); + // The peer's presence propagates separately from the join, so wait until + // theirPresence settles rather than reading it one-shot. + await waitForJson(page2, "#theirPresence", { foo: 1 }); await page1.close(); await page2.close(); diff --git a/e2e/next-sandbox/test/utils.ts b/e2e/next-sandbox/test/utils.ts index 4caa942c95d..071eb55c8ad 100644 --- a/e2e/next-sandbox/test/utils.ts +++ b/e2e/next-sandbox/test/utils.ts @@ -228,11 +228,68 @@ async function getBoth(pages: [Page, Page], selector: IDSelector) { return [value1, value2]; } +type TraceEntry = { t: number; dir: string; raw: string }; + +/** + * Reads the recorded WebSocket trace (see utils/recordingWebSocket.ts) from + * each page and prints it. Called when pages disagree, so a (usually flaky, + * timing-related) CI failure leaves the exact client/server protocol exchange + * in the logs instead of just "expected X to be Y". + */ +async function dumpDivergence( + pages: [Page, Page], + selector: IDSelector, + values: [Json | undefined, Json | undefined] +) { + /* eslint-disable no-console */ + console.error(`\n========== DIVERGENCE on #${selector} ==========`); + console.error(` page1: ${JSON.stringify(values[0])}`); + console.error(` page2: ${JSON.stringify(values[1])}`); + + for (let i = 0; i < pages.length; i++) { + // Full storage pool of this page's client (every node, its parent, its + // position key, and its value). `_dump` is @internal, present at runtime. + let pool: string | null = null; + try { + pool = await pages[i].evaluate( + () => + // @ts-expect-error -- _dump is @internal and not typed, but it exists at runtime + globalThis.__lbClient?._dump?.() ?? null + ); + } catch { + // Client absent or page closed; skip. + } + if (pool) { + console.error(`\n--- page${i + 1}: storage pool ---\n${pool}`); + } + + let trace: TraceEntry[] = []; + try { + trace = await pages[i].evaluate( + () => (globalThis as { __lbTrace?: TraceEntry[] }).__lbTrace ?? [] + ); + } catch { + // Page may be closed/navigated; skip. + } + console.error(`\n--- page${i + 1}: ${trace.length} WebSocket frames ---`); + for (const e of trace) { + console.error( + ` [${String(e.t).padStart(7)}ms ${e.dir.toUpperCase().padEnd(5)}] ${e.raw}` + ); + } + } + console.error(`================================================\n`); + /* eslint-enable no-console */ +} + export async function expectJsonEqualOnAllPages( pages: [Page, Page], selector: IDSelector ) { const [value1, value2] = await getBoth(pages, selector); + if (!_.isEqual(value1, value2)) { + await dumpDivergence(pages, selector, [value1, value2]); + } expect(value1).toEqual(value2); } diff --git a/e2e/next-sandbox/utils/createClient.ts b/e2e/next-sandbox/utils/createClient.ts index a70fe98e4ec..ab6004958b6 100644 --- a/e2e/next-sandbox/utils/createClient.ts +++ b/e2e/next-sandbox/utils/createClient.ts @@ -2,6 +2,8 @@ import type { BaseUserMeta, Client, ClientOptions } from "@liveblocks/client"; import { createClient as realCreateClient } from "@liveblocks/client"; import { nn } from "@liveblocks/core"; +import { RecordingWebSocket } from "./recordingWebSocket"; + const DEFAULT_E2E_OPTIONS = { authEndpoint: "/api/auth/access-token", }; @@ -15,7 +17,16 @@ export const DEFAULT_THROTTLE = 16; export function createLiveblocksClient( options: ClientOptions = DEFAULT_E2E_OPTIONS ): Client { - return realCreateClient(createLiveblocksClientOptions(options)); + const client = realCreateClient(createLiveblocksClientOptions(options)); + + // Expose the client so e2e failure dumps can call client._dump() via + // page.evaluate (the browser -> test bridge; see test/utils.ts). Test-only. + if (typeof window !== "undefined") { + // @ts-expect-error - Exposing internal client for testing purposes + window.__lbClient = client; + } + + return client; } export function createLiveblocksClientOptions( @@ -31,5 +42,12 @@ export function createLiveblocksClientOptions( process.env.NEXT_PUBLIC_LIVEBLOCKS_BASE_URL, "Please specify NEXT_PUBLIC_LIVEBLOCKS_BASE_URL env var" ), + + // Record all WebSocket traffic so flaky e2e failures can dump the exact + // protocol exchange (see recordingWebSocket.ts). Browser-only. + polyfills: { + ...options.polyfills, + ...(RecordingWebSocket ? { WebSocket: RecordingWebSocket } : {}), + }, }; } diff --git a/e2e/next-sandbox/utils/recordingWebSocket.ts b/e2e/next-sandbox/utils/recordingWebSocket.ts new file mode 100644 index 00000000000..c414b35ada4 --- /dev/null +++ b/e2e/next-sandbox/utils/recordingWebSocket.ts @@ -0,0 +1,56 @@ +/** + * A drop-in WebSocket that records every frame it sends and receives into a + * bounded in-page ring buffer (`window.__lbTrace`). The e2e tests dump this + * buffer when a (often flaky, timing-related) assertion fails, so CI logs show + * the exact client/server protocol exchange that led to the failure — without + * needing to reproduce it. + * + * Only used by the e2e sandbox app; never shipped. + */ + +export type TraceEntry = { + /** ms since the page loaded */ + t: number; + dir: "open" | "send" | "recv" | "close"; + raw: string; +}; + +const TRACE_MAX = 2000; // keep at most this many frames (ring buffer) +const RAW_MAX = 4000; // truncate each frame to this many chars + +function record(dir: TraceEntry["dir"], raw: string): void { + const w = globalThis as unknown as { __lbTrace?: TraceEntry[] }; + const buf = (w.__lbTrace ??= []); + buf.push({ + t: Math.round(performance.now()), + dir, + raw: raw.length > RAW_MAX ? raw.slice(0, RAW_MAX) + "…(truncated)" : raw, + }); + if (buf.length > TRACE_MAX) { + buf.shift(); + } +} + +// `class extends WebSocket` would throw during SSR where WebSocket is +// undefined, so only define it in the browser. createClient falls back to the +// platform WebSocket when this is undefined. +export const RecordingWebSocket = + typeof WebSocket === "undefined" + ? undefined + : class RecordingWebSocket extends WebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + record("open", String(url)); + this.addEventListener("message", (e: MessageEvent) => { + record("recv", typeof e.data === "string" ? e.data : ""); + }); + this.addEventListener("close", (e: CloseEvent) => { + record("close", `code=${e.code}${e.reason ? ` ${e.reason}` : ""}`); + }); + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { + record("send", typeof data === "string" ? data : ""); + super.send(data); + } + }; diff --git a/package.json b/package.json index faf89d00199..c130684077d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint": "turbo run lint", "lint:package": "turbo run lint:package", "format": "turbo run format", - "postinstall": "syncpack lint" + "prepare": "syncpack lint" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", diff --git a/packages/liveblocks-chat-sdk-adapter/package.json b/packages/liveblocks-chat-sdk-adapter/package.json index cb72574a41b..35ddbbda8e9 100644 --- a/packages/liveblocks-chat-sdk-adapter/package.json +++ b/packages/liveblocks-chat-sdk-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/chat-sdk-adapter", - "version": "3.19.3", + "version": "3.19.4", "description": "Liveblocks adapter for the Chat SDK.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -31,7 +31,7 @@ "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", "test": "vitest run", - "test:ci": "vitest run", + "test:ci": "vitest run --coverage", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/liveblocks-client/package.json b/packages/liveblocks-client/package.json index 25af1249f1c..e1d451c38c0 100644 --- a/packages/liveblocks-client/package.json +++ b/packages/liveblocks-client/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/client", - "version": "3.19.3", + "version": "3.19.4", "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", "author": "Liveblocks Inc.", @@ -31,7 +31,7 @@ "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", "test": "vitest run --passWithNoTests", - "test:ci": "vitest run --passWithNoTests", + "test:ci": "vitest run --passWithNoTests --coverage", "test:types": "vitest run --config ./vitest.config.typecheck.ts", "test:watch": "vitest" }, diff --git a/packages/liveblocks-core/e2e/list-push-optimistic.test.ts b/packages/liveblocks-core/e2e/list-push-optimistic.test.ts new file mode 100644 index 00000000000..e5e827e6d6d --- /dev/null +++ b/packages/liveblocks-core/e2e/list-push-optimistic.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "vitest"; + +import { LiveList } from "../src/crdts/LiveList"; +import { prepareTestsConflicts } from "./utils"; + +// The pushing client's OWN optimistic view should not reorder its just-pushed +// items while a concurrent remote push reconciles. The final converged order is +// server-authoritative; here we assert the originator never sees one of its own +// already-rendered items jump *backward* on the way there. +test( + "originator's pushed items never move backward under a concurrent remote push", + prepareTestsConflicts( + { + list: new LiveList([]), + }, + async ({ root1, root2, room1, control, assert }) => { + // Record every intermediate state client A (root1) renders for its list. + const states: string[][] = []; + const record = () => states.push(root1.get("list").toJSON() as string[]); + room1.subscribe(root1.get("list"), record, { isDeep: true }); + + // A pushes two items first; they stay unacked (A's outgoing is paused). + root1.get("list").push("a1"); + root1.get("list").push("a2"); + + // B pushes two items concurrently. flushB delivers both broadcasts to A + // *while a1/a2 are still unacked* (incoming isn't paused), forcing A to + // reconcile. + root2.get("list").push("b1"); + root2.get("list").push("b2"); + await control.flushB(); + + // A now sends its own a1/a2 and receives their acks. + await control.flushA(); + + // Final converged order. + assert({ list: ["b1", "b2", "a1", "a2"] }); + + // Invariant: in a pure-push sequence, no already-rendered item may ever + // decrease in index. A's own a1/a2 must never jump backward. + for (const item of new Set(states.flat())) { + let lastIndex = -1; + for (const state of states) { + const idx = state.indexOf(item); + if (idx === -1) continue; + if (idx < lastIndex) { + throw new Error( + `"${item}" moved backward (index ${lastIndex} → ${idx}). ` + + `Observed sequence: ${JSON.stringify(states)}` + ); + } + lastIndex = idx; + } + } + expect(states.at(-1)).toEqual(["b1", "b2", "a1", "a2"]); + } + ) +); diff --git a/packages/liveblocks-core/e2e/list-push-reconnect-divergence.test.ts b/packages/liveblocks-core/e2e/list-push-reconnect-divergence.test.ts new file mode 100644 index 00000000000..c9ac62011b5 --- /dev/null +++ b/packages/liveblocks-core/e2e/list-push-reconnect-divergence.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "vitest"; + +import { LiveList } from "../src/crdts/LiveList"; +import { prepareTestsConflicts } from "./utils"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function waitUntil( + predicate: () => boolean, + description: string, + timeoutMs = 10_000 +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) { + return; + } + await sleep(50); + } + throw new Error(`Timed out waiting until ${description} (${timeoutMs}ms)`); +} + +/** + * Deterministic reproduction of the offline.test.ts "client synchronizes + * offline changes" divergence. + * + * The trigger is an item that the server has already stored but that the + * pushing client still considers pending (unacknowledged), because the client + * never received the ack. On reconnect, the client's optimistic tail-bump + * moves that pending push past a sibling the other client added, re-sends it at + * its original key, and the server bare-acks it (already stored, no + * reposition), so the bump is never undone. The two clients then disagree on + * the order. + */ +test( + "a pending push the server already stored keeps its server position after reconnect", + prepareTestsConflicts( + { list: new LiveList([]) }, + async ({ root1, root2, room1, control }) => { + const list1 = root1.get("list"); + const list2 = root2.get("list"); + + // 1. Client A pushes P and flushes it to the server, but drops every + // incoming message first, so the server's ack/echo never reaches A: P + // is stored server-side (so B sees it) yet stays *pending* on A. + list1.push("P"); + control.dropIncomingA(); + control.flushSyncA(); + await waitUntil( + () => [...list2].includes("P"), + "Client B sees P (server stored it)" + ); + + // 2. A disconnects while P is still pending locally. + room1.disconnect(); + + // 3. B pushes Q; the server appends it after P. + list2.push("Q"); + control.flushSyncB(); + await waitUntil( + () => [...list2].join(",") === "P,Q", + "Client B sees [P, Q]" + ); + + // 4. A reconnects. The snapshot carries both P and Q; A's tail-bump moves + // its still-pending P past Q, then re-sends P at its original key. The + // server bare-acks (P already there), so A's bump is never undone. + room1.reconnect(); + + await waitUntil( + () => [...list1].length === 2 && [...list2].length === 2, + "both clients have 2 items again" + ); + + // Let any acks settle. + await sleep(500); + + // Both clients must agree on the server's order, [P, Q]. + expect([...list1]).toEqual([...list2]); + expect([...list2]).toEqual(["P", "Q"]); + } + ) +); diff --git a/packages/liveblocks-core/e2e/list-push.test.ts b/packages/liveblocks-core/e2e/list-push.test.ts new file mode 100644 index 00000000000..33b72951735 --- /dev/null +++ b/packages/liveblocks-core/e2e/list-push.test.ts @@ -0,0 +1,29 @@ +import { test } from "vitest"; + +import { LiveList } from "../src/crdts/LiveList"; +import { prepareTestsConflicts } from "./utils"; + +// Two actors append to the same LiveList near-simultaneously: client A appends +// a1 then a2; client B appends b1 without yet having seen a1/a2, so b1 guesses +// the head position. By the time b1 reaches the server, a1 and a2 are already +// stored, and the position conflict is resolved *between* them — so the list +// settles as [a1, b1, a2] instead of append order [a1, a2, b1]. +// A server-authoritative append must place b1 at the true end. +test( + "concurrent pushes settle in append order, never wedged", + prepareTestsConflicts( + { + list: new LiveList([]), + }, + async ({ root1, root2, control, assert }) => { + root1.get("list").push("a1"); + root1.get("list").push("a2"); + root2.get("list").push("b1"); + + await control.flushA(); + await control.flushB(); + + assert({ list: ["a1", "a2", "b1"] }); + } + ) +); diff --git a/packages/liveblocks-core/e2e/storage-notification-reconnect.test.ts b/packages/liveblocks-core/e2e/storage-notification-reconnect.test.ts new file mode 100644 index 00000000000..de7488d3c87 --- /dev/null +++ b/packages/liveblocks-core/e2e/storage-notification-reconnect.test.ts @@ -0,0 +1,806 @@ +/** + * Notification-equivalence tests for the reconnect path. + * + * Each test calls `bothPhases`, which runs the same `mutate` function in two + * fully-independent fresh room pairs that share the same `initialStorage`: + * + * Phase 1 (online): A is connected; B makes mutations; A receives + * them as live ops and fires notifications. + * Phase 2 (offline+reconnect): A disconnects; B makes the same mutations; A + * reconnects and receives them as a snapshot. + * + * Because both phases start from identical state and apply identical mutations, + * the resulting StorageUpdate batches must be equal. Each test asserts that + * equivalence — this is the spec for the node-stream reconcile refactor (see + * tech-design-node-stream-reconcile.md) and must pass on the current + * diff+apply path before any _reconcile code is written. + * + * NOTE ON CONTROL KEYS: several LiveObject tests carry an unchanged scalar key + * (e.g. `keep`) that the mutation never touches. The reconnect path routes a + * snapshot through `getTreesDiffOperations`, which re-sends the *full* + * UPDATE_OBJECT data — so an unchanged key can be spuriously re-notified. The + * control key is what makes that bug observable; do not remove it. + */ +import { expect, onTestFinished, test } from "vitest"; +import WebSocket from "ws"; + +import { nanoid } from "../src"; +import { createClient } from "../src/client"; +import { LiveList } from "../src/crdts/LiveList"; +import { LiveMap } from "../src/crdts/LiveMap"; +import { LiveObject } from "../src/crdts/LiveObject"; +import type { LsonObject } from "../src/crdts/Lson"; +import type { StorageUpdate } from "../src/crdts/StorageUpdates"; +import type { Json, JsonObject } from "../src/lib/Json"; +import type { Room } from "../src/room"; + +// ───────────────────────────────────────────────────────────────────────────── +// Infrastructure +// ───────────────────────────────────────────────────────────────────────────── + +const BASE_URL = `http://localhost:${process.env.LIVEBLOCKS_DEV_SERVER_PORT ?? 1154}`; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function waitUntil( + predicate: () => boolean, + description: string, + timeoutMs = 10_000 +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await sleep(50); + } + throw new Error(`Timed out waiting for: ${description} (${timeoutMs}ms)`); +} + +type Actor = { + room: Room; + root: LiveObject; +}; + +// Create one room participant and wait until it is connected with storage +// loaded. The first actor to join a room creates it with `initialStorage`; +// later joiners pass `{}` and receive the existing storage from the server. +async function createActor( + roomId: string, + initialStorage: S +): Promise> { + const client = createClient({ + __DANGEROUSLY_disableThrottling: true, + publicApiKey: "pk_localdev", + polyfills: { + WebSocket: WebSocket as unknown as typeof globalThis.WebSocket, + }, + baseUrl: BASE_URL, + }); + + // XXX enterRoom needs explicit generics + a cast to accept the test options; + // mirrors the pattern in e2e/utils.ts. + const { room, leave } = client.enterRoom(roomId, { + initialPresence: {}, + initialStorage, + } as never); + + await waitUntil( + () => room.getStatus() === "connected", + `room ${roomId} connected` + ); + + const { root } = await room.getStorage(); + onTestFinished(() => leave()); + + return { room: room as Room, root }; +} + +// A JSON.stringify replacer that emits object keys in sorted order, so two +// structurally-equal trees stringify identically. LiveObject key order differs +// between a locally-built tree and one loaded from the server (inline `data` +// keys vs child nodes merge in different orders), so a plain JSON.stringify +// comparison is not reliable. Returning a key-sorted copy makes stringify walk +// the keys in canonical order (it recurses into the returned object's values). +function sortKeys(_key: string, value: unknown): unknown { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value as Record).sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0 + ) + ); +} + +const sameJson = (a: Actor, b: Actor): boolean => + JSON.stringify(a.root.toJSON(), sortKeys) === + JSON.stringify(b.root.toJSON(), sortKeys); + +// ───────────────────────────────────────────────────────────────────────────── +// bothPhases +// ───────────────────────────────────────────────────────────────────────────── + +// Run `mutate` in two independent fresh room pairs with the same +// `initialStorage`, and return the StorageUpdate batches A receives in each: +// +// online.batches — A stayed connected; got B's mutations as live ops. +// reconnect.batches — A disconnected; got B's mutations via a fresh snapshot. +// +// Both phases start from the same state and apply the same mutations, so the +// batches should be structurally identical. Each test asserts that. +// +// We capture only the notifications that result from B's mutations: in each +// phase A's subscription is attached right before those mutations land (online) +// or right before the reconnect snapshot is applied (reconnect), so no +// connection/handshake noise leaks into the batches. +async function bothPhases( + initialStorageFn: () => S, + mutate: (root: LiveObject) => void, + opts: { convergeTimeoutMs?: number } = {} +): Promise<{ + online: { batches: StorageUpdate[][]; root: LiveObject }; + reconnect: { batches: StorageUpdate[][]; root: LiveObject }; +}> { + const convergeTimeoutMs = opts.convergeTimeoutMs ?? 10_000; + + // ── Phase 1: online ────────────────────────────────────────────────────── + const roomId1 = "notif-equiv-" + nanoid(); + const a1 = await createActor(roomId1, initialStorageFn()); + const b1 = await createActor(roomId1, initialStorageFn()); + + const onlineBatches: StorageUpdate[][] = []; + a1.room.subscribe(a1.root, (u) => onlineBatches.push(u), { isDeep: true }); + + mutate(b1.root); + await waitUntil( + () => sameJson(a1, b1), + "Phase 1: A converges to B", + convergeTimeoutMs + ); + await sleep(50); // settle async notification delivery + + // ── Phase 2: offline + reconnect ───────────────────────────────────────── + const roomId2 = "notif-equiv-" + nanoid(); + const a2 = await createActor(roomId2, initialStorageFn()); + const b2 = await createActor(roomId2, initialStorageFn()); + + // A goes offline; B applies the mutations and flushes them to the server. + a2.room.disconnect(); + mutate(b2.root); + // Wait until the server has acknowledged B's ops (so a reconnect snapshot is + // guaranteed to include them), rather than guessing with a fixed sleep. + await waitUntil( + () => b2.room.getStorageStatus() === "synchronized", + "B's changes acknowledged by the server" + ); + + // Subscribe only now, so the batches contain exactly the reconnect reconcile. + const reconnectBatches: StorageUpdate[][] = []; + a2.room.subscribe(a2.root, (u) => reconnectBatches.push(u), { isDeep: true }); + + a2.room.reconnect(); + await waitUntil( + () => sameJson(a2, b2), + "Phase 2: A converges to B after reconnect", + convergeTimeoutMs + ); + await sleep(50); // settle async notification delivery + + return { + online: { batches: onlineBatches, root: a1.root }, + reconnect: { batches: reconnectBatches, root: a2.root }, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Batch reducers +// ───────────────────────────────────────────────────────────────────────────── + +type ListUpdate = Extract; +type ObjUpdate = Extract; +type MapUpdate = Extract; + +// StorageUpdate[][] (one array per notification batch) +// → flat array of LiveListUpdateDelta for the given node +// +// [ [{type:"LiveList", node:list1, updates:[{type:"insert",index:0,item:"a"}]}], +// [{type:"LiveList", node:list1, updates:[{type:"insert",index:1,item:"b"}]}] ] +// ↓ +// [ {type:"insert",index:0,item:"a"}, {type:"insert",index:1,item:"b"} ] +function collectListDeltas( + batches: StorageUpdate[][], + targetNode: object +): ListUpdate["updates"] { + return batches + .flat() + .filter( + (u): u is ListUpdate => + u.type === "LiveList" && (u.node as object) === targetNode + ) + .flatMap((u) => u.updates); +} + +// StorageUpdate[][] (one array per notification batch) +// → single merged LiveObjectUpdateDelta for the given node +// +// [ [{type:"LiveObject", node:obj1, updates:{x:{type:"update"}}}], +// [{type:"LiveObject", node:obj1, updates:{z:{type:"update"}}}] ] +// ↓ +// { x: {type:"update"}, z: {type:"update"} } +function mergeObjUpdates( + batches: StorageUpdate[][], + targetNode: object +): ObjUpdate["updates"] { + return batches + .flat() + .filter( + (u): u is ObjUpdate => + u.type === "LiveObject" && (u.node as object) === targetNode + ) + .reduce((acc, u) => ({ ...acc, ...u.updates }), {}); +} + +// StorageUpdate[][] (one array per notification batch) +// → single merged LiveMapUpdates.updates for the given node +// +// [ [{type:"LiveMap", node:map1, updates:{x:{type:"update"}}}], +// [{type:"LiveMap", node:map1, updates:{b:{type:"delete",deletedItem:2}}}] ] +// ↓ +// { x: {type:"update"}, b: {type:"delete", deletedItem:2} } +function mergeMapUpdates( + batches: StorageUpdate[][], + targetNode: object +): MapUpdate["updates"] { + return batches + .flat() + .filter( + (u): u is MapUpdate => + u.type === "LiveMap" && (u.node as object) === targetNode + ) + .reduce((acc, u) => ({ ...acc, ...u.updates }), {}); +} + +// ───────────────────────────────────────────────────────────────────────────── +// LiveList +// ───────────────────────────────────────────────────────────────────────────── + +test("LiveList: inserts fire equivalent notifications online and on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ list: new LiveList([]) }), + (r) => { + r.get("list").push("a"); + r.get("list").push("b"); + } + ); + const expected = [ + { type: "insert", index: 0, item: "a" }, + { type: "insert", index: 1, item: "b" }, + ]; + expect(collectListDeltas(online.batches, online.root.get("list"))).toEqual( + expected + ); + expect( + collectListDeltas(reconnect.batches, reconnect.root.get("list")) + ).toEqual(expected); +}); + +test("LiveList: deletes fire equivalent notifications online and on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ list: new LiveList(["a", "b", "c"]) }), + (r) => { + r.get("list").delete(1); // remove "b" + } + ); + const expected = [{ type: "delete", index: 1, deletedItem: "b" }]; + expect(collectListDeltas(online.batches, online.root.get("list"))).toEqual( + expected + ); + expect( + collectListDeltas(reconnect.batches, reconnect.root.get("list")) + ).toEqual(expected); +}); + +test("LiveList: moves fire equivalent notifications online and on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ list: new LiveList(["a", "b", "c"]) }), + (r) => { + r.get("list").move(2, 0); // move "c" to front + } + ); + const expected = [{ type: "move", previousIndex: 2, index: 0, item: "c" }]; + expect(collectListDeltas(online.batches, online.root.get("list"))).toEqual( + expected + ); + expect( + collectListDeltas(reconnect.batches, reconnect.root.get("list")) + ).toEqual(expected); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// LiveObject +// ───────────────────────────────────────────────────────────────────────────── + +test("LiveObject: updates/adds fire equivalent notifications online and on reconnect", async () => { + const { online, reconnect } = await bothPhases( + // `keep` is a control key the mutation never touches (see file header). + () => ({ + obj: new LiveObject<{ x: number; keep: number; z?: string }>({ + x: 1, + keep: 0, + }), + }), + (r) => { + r.get("obj").set("x", 99); + r.get("obj").set("z", "new"); + } + ); + const expected = { x: { type: "update" }, z: { type: "update" } }; + expect(mergeObjUpdates(online.batches, online.root.get("obj"))).toEqual( + expected + ); + expect(mergeObjUpdates(reconnect.batches, reconnect.root.get("obj"))).toEqual( + expected + ); +}); + +test("LiveObject: untouched scalar keys are never re-notified online or on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ obj: new LiveObject({ a: 1, b: 2, c: 3 }) }), + (r) => { + r.get("obj").set("a", 99); // b and c are untouched + } + ); + const expected = { a: { type: "update" } }; + expect(mergeObjUpdates(online.batches, online.root.get("obj"))).toEqual( + expected + ); + expect(mergeObjUpdates(reconnect.batches, reconnect.root.get("obj"))).toEqual( + expected + ); +}); + +test("LiveObject: deleting a scalar key does not re-notify a surviving sibling", async () => { + const { online, reconnect } = await bothPhases( + () => ({ + obj: new LiveObject<{ a: number; b?: number }>({ a: 1, b: 2 }), + }), + (r) => { + r.get("obj").delete("b"); // a survives and must not be re-notified + } + ); + const expected = { b: { type: "delete", deletedItem: 2 } }; + expect(mergeObjUpdates(online.batches, online.root.get("obj"))).toEqual( + expected + ); + expect(mergeObjUpdates(reconnect.batches, reconnect.root.get("obj"))).toEqual( + expected + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// LiveMap +// ───────────────────────────────────────────────────────────────────────────── + +test("LiveMap: updates/adds fire equivalent notifications online and on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ map: new LiveMap([["x", 1]]) }), + (r) => { + r.get("map").set("x", 99); + r.get("map").set("z", 7); + } + ); + const expected = { x: { type: "update" }, z: { type: "update" } }; + expect(mergeMapUpdates(online.batches, online.root.get("map"))).toEqual( + expected + ); + expect(mergeMapUpdates(reconnect.batches, reconnect.root.get("map"))).toEqual( + expected + ); +}); + +test("LiveMap: deletes fire equivalent notifications online and on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ + map: new LiveMap([ + ["a", 1], + ["b", 2], + ]), + }), + (r) => { + r.get("map").delete("b"); + } + ); + const expected = { b: { type: "delete", deletedItem: 2 } }; + expect(mergeMapUpdates(online.batches, online.root.get("map"))).toEqual( + expected + ); + expect(mergeMapUpdates(reconnect.batches, reconnect.root.get("map"))).toEqual( + expected + ); +}); + +test("LiveObject: scalar deletes fire equivalent notifications online and on reconnect", async () => { + // Delete the object's sole key, so there is no surviving scalar sibling whose + // full-data re-send would trip the separate spurious-update issue (covered by + // "untouched scalar keys"). This keeps the test focused on the delete itself. + const { online, reconnect } = await bothPhases( + () => ({ obj: new LiveObject<{ b?: number }>({ b: 2 }) }), + (r) => { + r.get("obj").delete("b"); + } + ); + const expected = { b: { type: "delete", deletedItem: 2 } }; + expect(mergeObjUpdates(online.batches, online.root.get("obj"))).toEqual( + expected + ); + expect(mergeObjUpdates(reconnect.batches, reconnect.root.get("obj"))).toEqual( + expected + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// LiveObject key transitions: scalar ↔ nested live structure +// +// Scalars are stored in the LiveObject's own `data` field (serialized inline). +// Nested live structures become separate nodes in the pool (separate parentId +// chain). The two storage representations produce different ops on reconnect: +// scalar changes go through UPDATE_OBJECT / DELETE_OBJECT_KEY while nested +// node changes go through CREATE_*/DELETE_CRDT. Each test carries a `keep` +// control scalar so the snapshot's full-data UPDATE_OBJECT is exercised. +// ───────────────────────────────────────────────────────────────────────────── + +test("LiveObject: nested-object deletes fire equivalent notifications online and on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ + obj: new LiveObject<{ + child: LiveObject<{ x: number }>; + sibling: LiveObject<{ y: number }>; + keep: number; + }>({ + child: new LiveObject({ x: 1 }), + sibling: new LiveObject({ y: 2 }), + keep: 0, + }), + }), + (r) => { + r.get("obj").delete("child"); + } + ); + // Deleting a nested live node fires { type: "delete", deletedItem } where + // deletedItem is the removed LiveObject. Compare its JSON, since the delta + // carries a live node instance. + const deletedChildJson = (updates: ObjUpdate["updates"]): unknown => { + const delta = updates.child; + return delta?.type === "delete" && delta.deletedItem instanceof LiveObject + ? delta.deletedItem.toJSON() + : delta; + }; + expect( + deletedChildJson(mergeObjUpdates(online.batches, online.root.get("obj"))) + ).toEqual({ x: 1 }); + expect( + deletedChildJson( + mergeObjUpdates(reconnect.batches, reconnect.root.get("obj")) + ) + ).toEqual({ x: 1 }); +}); + +// Baseline (passes today): when the transitioned key is the object's *only* +// scalar, moving it into a child node empties the object's `data`, so the +// snapshot diff produces an UPDATE_OBJECT with empty data — nothing left for +// the full-data re-send to spuriously re-notify. Contrast with the next test, +// which adds a surviving scalar sibling and exposes that exact leak. +test("LiveObject: scalar→nested-object transition (sole key) fires equivalent notifications online and on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ + obj: new LiveObject<{ a: number | LiveObject<{ x: number }> }>({ a: 1 }), + }), + (r) => { + r.get("obj").set("a", new LiveObject({ x: 10 })); + } + ); + // _attachChild always fires { type: "update" } regardless of the previous + // value type (scalar or live node). + const expected = { a: { type: "update" } }; + expect(mergeObjUpdates(online.batches, online.root.get("obj"))).toEqual( + expected + ); + expect(mergeObjUpdates(reconnect.batches, reconnect.root.get("obj"))).toEqual( + expected + ); +}); + +test("LiveObject: scalar→nested-object transition fires equivalent notifications online and on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ + obj: new LiveObject<{ + a: number | LiveObject<{ x: number }>; + keep: number; + }>({ a: 1, keep: 0 }), + }), + (r) => { + r.get("obj").set("a", new LiveObject({ x: 10 })); + } + ); + // _attachChild always fires { type: "update" } regardless of the previous + // value type (scalar or live node). + const expected = { a: { type: "update" } }; + expect(mergeObjUpdates(online.batches, online.root.get("obj"))).toEqual( + expected + ); + expect(mergeObjUpdates(reconnect.batches, reconnect.root.get("obj"))).toEqual( + expected + ); +}); + +test("LiveObject: nested-object→scalar transition fires equivalent notifications online and on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ + obj: new LiveObject<{ + nested: LiveObject<{ x: number }> | number; + keep: number; + }>({ + nested: new LiveObject({ x: 1 }), + keep: 0, + }), + }), + (r) => { + r.get("obj").set("nested", 99); + } + ); + // Replacing a live node with a scalar: _detachChild fires { type: "delete" } + // then #applyUpdate fires { type: "update" }; merging last-write-wins gives + // { type: "update" }. + const expected = { nested: { type: "update" } }; + expect(mergeObjUpdates(online.batches, online.root.get("obj"))).toEqual( + expected + ); + expect(mergeObjUpdates(reconnect.batches, reconnect.root.get("obj"))).toEqual( + expected + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Allowed divergence: collapse +// +// The reconnect snapshot carries only the *final* state, not the intermediate +// steps B went through while A was offline. So when B makes multiple changes to +// the same region offline, A's reconnect notifications collapse to the net +// delta, whereas the online path sees every intermediate step. This is the one +// divergence the tech design permits. +// +// These tests therefore do NOT assert online === reconnect. They assert: +// - both phases converge to the same final state (bothPhases already waits on +// this), and +// - the online path saw the intermediate churn while the reconnect path saw +// only the collapsed net result. +// +// Unlike the bug-spec tests above, these are expected to PASS on the current +// path — they lock in the collapse semantics so the reconcile refactor can't +// regress them. +// ───────────────────────────────────────────────────────────────────────────── + +const insertedItems = (deltas: ListUpdate["updates"]): unknown[] => + deltas + .filter((d) => d.type === "insert") + .map((d) => (d as { item: unknown }).item); + +test("collapse: a net-zero list change (insert then delete) notifies online but is silent on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ list: new LiveList(["a"]) }), + (r) => { + r.get("list").push("x"); // ["a", "x"] + r.get("list").delete(1); // ["a"] — net change is nothing + } + ); + + // Online saw the churn: an insert of "x" and its delete. + const onlineDeltas = collectListDeltas( + online.batches, + online.root.get("list") + ); + expect(onlineDeltas).toEqual([ + { type: "insert", index: 1, item: "x" }, + { type: "delete", index: 1, deletedItem: "x" }, + ]); + + // Reconnect snapshot equals A's pre-disconnect state, so nothing is notified. + expect( + collectListDeltas(reconnect.batches, reconnect.root.get("list")) + ).toEqual([]); + + // Both converge to the unchanged list. + expect(online.root.get("list").toJSON()).toEqual(["a"]); + expect(reconnect.root.get("list").toJSON()).toEqual(["a"]); +}); + +test("collapse: a net-zero object change (add then delete a key) notifies online but is silent on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ obj: new LiveObject<{ a: number; b?: number }>({ a: 1 }) }), + (r) => { + r.get("obj").set("b", 2); // { a: 1, b: 2 } + r.get("obj").delete("b"); // { a: 1 } — net change is nothing + } + ); + + // Online saw "b" appear and disappear (merged last-write-wins to its delete). + expect(mergeObjUpdates(online.batches, online.root.get("obj"))).toEqual({ + b: { type: "delete", deletedItem: 2 }, + }); + + // Reconnect snapshot equals A's pre-disconnect state, so nothing is notified. + expect(mergeObjUpdates(reconnect.batches, reconnect.root.get("obj"))).toEqual( + {} + ); + + expect(online.root.get("obj").toJSON()).toEqual({ a: 1 }); + expect(reconnect.root.get("obj").toJSON()).toEqual({ a: 1 }); +}); + +test("collapse: an intermediate list item B adds then removes offline is never seen on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ list: new LiveList([]) }), + (r) => { + r.get("list").push("a"); + r.get("list").push("b"); // intermediate — removed below + r.get("list").push("c"); + r.get("list").delete(1); // remove "b" → net ["a", "c"] + } + ); + + // Online witnessed "b" being inserted (and later deleted)... + const onlineDeltas = collectListDeltas( + online.batches, + online.root.get("list") + ); + expect(insertedItems(onlineDeltas)).toContain("b"); + + // ...but the reconnect snapshot only carries the net result: a, c. "b" never + // appears. + const reconnectDeltas = collectListDeltas( + reconnect.batches, + reconnect.root.get("list") + ); + expect(reconnectDeltas).toEqual([ + { type: "insert", index: 0, item: "a" }, + { type: "insert", index: 1, item: "c" }, + ]); + + expect(online.root.get("list").toJSON()).toEqual(["a", "c"]); + expect(reconnect.root.get("list").toJSON()).toEqual(["a", "c"]); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// LiveList.set, nested live items, deep trees, and map control key +// ───────────────────────────────────────────────────────────────────────────── + +// For list deltas whose `item`/`deletedItem` is a live node (not a scalar), +// compare only the structural shape (type + index). Item *content* is asserted +// separately via the converged final state. +const listShapes = ( + deltas: ListUpdate["updates"] +): { type: string; index: number }[] => + deltas.map((d) => ({ type: d.type, index: d.index })); + +test.fails( + "LiveList: set (replace at index) fires equivalent notifications online and on reconnect", + async () => { + const { online, reconnect } = await bothPhases( + () => ({ list: new LiveList(["a", "b", "c"]) }), + (r) => { + r.get("list").set(1, "B"); // replace "b" with "B" + } + ); + // Online: a single "set" delta. On reconnect the old register is gone and a + // new one is created, so the diff path may instead emit delete+insert — this + // is one of the divergences the reconcile refactor must eliminate. + const expected = [{ type: "set", index: 1, item: "B" }]; + expect(collectListDeltas(online.batches, online.root.get("list"))).toEqual( + expected + ); + expect( + collectListDeltas(reconnect.batches, reconnect.root.get("list")) + ).toEqual(expected); + } +); + +test("LiveList: inserting a nested LiveObject fires equivalent notifications online and on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ list: new LiveList>([]) }), + (r) => { + r.get("list").push(new LiveObject({ v: 1 })); + } + ); + const expectedShape = [{ type: "insert", index: 0 }]; + expect( + listShapes(collectListDeltas(online.batches, online.root.get("list"))) + ).toEqual(expectedShape); + expect( + listShapes(collectListDeltas(reconnect.batches, reconnect.root.get("list"))) + ).toEqual(expectedShape); + expect(online.root.get("list").toJSON()).toEqual([{ v: 1 }]); + expect(reconnect.root.get("list").toJSON()).toEqual([{ v: 1 }]); +}); + +test("LiveList: deleting a nested LiveObject fires equivalent notifications online and on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ + list: new LiveList>([ + new LiveObject({ v: 1 }), + new LiveObject({ v: 2 }), + ]), + }), + (r) => { + r.get("list").delete(0); + } + ); + const expectedShape = [{ type: "delete", index: 0 }]; + expect( + listShapes(collectListDeltas(online.batches, online.root.get("list"))) + ).toEqual(expectedShape); + expect( + listShapes(collectListDeltas(reconnect.batches, reconnect.root.get("list"))) + ).toEqual(expectedShape); + expect(online.root.get("list").toJSON()).toEqual([{ v: 2 }]); + expect(reconnect.root.get("list").toJSON()).toEqual([{ v: 2 }]); +}); + +test("deep tree: inserting a nested subtree fires equivalent notifications online and on reconnect", async () => { + type Leaf = LiveObject<{ n: number }>; + type Item = LiveObject<{ label: string; kids: LiveList }>; + const { online, reconnect } = await bothPhases( + () => ({ + tree: new LiveObject<{ items: LiveList }>({ + items: new LiveList([]), + }), + }), + (r) => { + // A whole parent→child→grandchild subtree, which on reconnect must attach + // parents-before-children for the snapshot to reconcile correctly. + r.get("tree") + .get("items") + .push( + new LiveObject({ + label: "x", + kids: new LiveList([new LiveObject({ n: 1 })]), + }) + ); + } + ); + + const onlineItems = online.root.get("tree").get("items"); + const reconnectItems = reconnect.root.get("tree").get("items"); + + const expectedShape = [{ type: "insert", index: 0 }]; + expect(listShapes(collectListDeltas(online.batches, onlineItems))).toEqual( + expectedShape + ); + expect( + listShapes(collectListDeltas(reconnect.batches, reconnectItems)) + ).toEqual(expectedShape); + + const expectedTree = { items: [{ label: "x", kids: [{ n: 1 }] }] }; + expect(online.root.get("tree").toJSON()).toEqual(expectedTree); + expect(reconnect.root.get("tree").toJSON()).toEqual(expectedTree); +}); + +test("LiveMap: untouched keys are never re-notified online or on reconnect", async () => { + const { online, reconnect } = await bothPhases( + () => ({ + map: new LiveMap([ + ["x", 1], + ["keep", 0], + ]), + }), + (r) => { + r.get("map").set("x", 99); // keep is untouched + } + ); + // Map values are stored as child nodes (not inline data), so an unchanged key + // is a node absent from the diff — it must never be re-notified. + const expected = { x: { type: "update" } }; + expect(mergeMapUpdates(online.batches, online.root.get("map"))).toEqual( + expected + ); + expect(mergeMapUpdates(reconnect.batches, reconnect.root.get("map"))).toEqual( + expected + ); +}); diff --git a/packages/liveblocks-core/e2e/utils.ts b/packages/liveblocks-core/e2e/utils.ts index 48eb6760f00..c7b71bcccb6 100644 --- a/packages/liveblocks-core/e2e/utils.ts +++ b/packages/liveblocks-core/e2e/utils.ts @@ -29,6 +29,7 @@ async function initializeRoomForTest< class PausableWebSocket extends WebSocket { sendBuffer: string[] = []; _isSendPaused = false; + _dropIncoming = false; constructor(address: string | URL) { super(address); @@ -63,6 +64,28 @@ async function initializeRoomForTest< super.send(data); } } + + /** + * Silently drops every message the server sends from now on, as if the + * network ate them. Used to keep an op "pending" on this client even + * though the server already received and processed it: the server's + * ack/echo never reaches the client, so it never clears from + * unacknowledgedOps. + */ + dropIncoming() { + this._dropIncoming = true; + } + + // `ws` delivers incoming frames by emitting a "message" event (both + // addEventListener and .on() listeners run through this). Swallow those + // emissions while dropping, leaving every other event untouched. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches EventEmitter.emit's own signature + emit(eventName: string | symbol, ...args: any[]): boolean { + if (this._dropIncoming && eventName === "message") { + return false; + } + return super.emit(eventName, ...args); + } } const client = createClient({ @@ -126,6 +149,30 @@ export function prepareTestsConflicts( * until Client A has processed them. */ flushB: () => Promise; + /** + * Flushes Client A's buffered sends to the server without waiting for a + * beacon round-trip. Use when Client A is dropping incoming messages (so + * a beacon would never return). + */ + flushSyncA: () => void; + /** + * Flushes Client B's buffered sends to the server without waiting for a + * beacon round-trip. Use when Client B is dropping incoming messages (so + * a beacon would never return). + */ + flushSyncB: () => void; + /** + * Makes client A silently drop every message the server sends from now + * on, keeping its in-flight ops "pending" even after the server has + * processed them. + */ + dropIncomingA: () => void; + /** + * Makes client B silently drop every message the server sends from now + * on, keeping its in-flight ops "pending" even after the server has + * processed them. + */ + dropIncomingB: () => void; }; }) => Promise ): () => Promise { @@ -229,6 +276,24 @@ export function prepareTestsConflicts( "Client A did not receive beacon from Client B within 8s" ); }, + + flushSyncA: () => { + actor1.ws.resume(); + actor1.ws.pause(); + }, + + flushSyncB: () => { + actor2.ws.resume(); + actor2.ws.pause(); + }, + + dropIncomingA: () => { + actor1.ws.dropIncoming(); + }, + + dropIncomingB: () => { + actor2.ws.dropIncoming(); + }, }; actor1.ws.pause(); @@ -287,6 +352,13 @@ export function prepareTestsConflicts( actor1.leave(); actor2.leave(); } catch (er) { + // Surface the full storage pool of both clients (every node, its parent, + // its position key, and its value) so convergence failures are debuggable + // from the test output alone. + // eslint-disable-next-line no-console + console.error( + `\n=== Storage pool dump on failure ===\n${actor1.room._dump()}\n\n${actor2.room._dump()}\n` + ); actor1.leave(); actor2.leave(); throw er; diff --git a/packages/liveblocks-core/package.json b/packages/liveblocks-core/package.json index 7420d3745a6..450f167d816 100644 --- a/packages/liveblocks-core/package.json +++ b/packages/liveblocks-core/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/core", - "version": "3.19.3", + "version": "3.19.4", "description": "Private internals for Liveblocks. DO NOT import directly from this package!", "type": "module", "main": "./dist/index.cjs", @@ -37,11 +37,11 @@ "format": "(eslint --fix src/ e2e/ || true) && prettier --write src/ e2e/", "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", - "test": "pnpm dlx liveblocks dev -p 1160 -c 'vitest run --coverage'", - "test:ci": "vitest run", + "test": "pnpm dlx liveblocks dev -P -c 'vitest run --coverage'", + "test:ci": "pnpm dlx liveblocks dev -P -c 'vitest run --coverage'", "test:types": "vitest run --config ./vitest.config.typecheck.ts", "test:watch": "vitest", - "test:e2e": "pnpm dlx liveblocks dev -p 1160 -c 'vitest run --config=./vitest.config.e2e.ts'", + "test:e2e": "pnpm dlx liveblocks dev -P -c 'vitest run --config=./vitest.config.e2e.ts'", "test:deps": "depcruise src --exclude __tests__", "showdeps": "depcruise src --include-only '^src' --exclude='__tests__' --output-type dot | dot -T svg > /tmp/dependency-graph.svg && open /tmp/dependency-graph.svg", "showdeps:high-level": "depcruise src --include-only '^src' --exclude='(^src/index.ts|shallow.ts|__tests__)' --collapse='^src/(refs|lib|compat|types|crdts|protocol)' --output-type dot | dot -T svg > /tmp/dependency-graph.svg && open /tmp/dependency-graph.svg" diff --git a/packages/liveblocks-core/src/client.ts b/packages/liveblocks-core/src/client.ts index ee55ed83b6f..e111d25b8cb 100644 --- a/packages/liveblocks-core/src/client.ts +++ b/packages/liveblocks-core/src/client.ts @@ -394,6 +394,13 @@ export type Client< roomId: string ): Room | null; + /** + * @internal + * Returns a human-readable dump of every storage node in every room this + * client has entered. For debugging convergence issues only. + */ + _dump(): string; + /** * Enter a room. * @param roomId The id of the room @@ -1045,6 +1052,9 @@ export function createClient( enterRoom, getRoom, + _dump: () => + Array.from(roomsById.values(), ({ room }) => room._dump()).join("\n\n"), + logout, // Public inbox notifications API diff --git a/packages/liveblocks-core/src/crdts/AbstractCrdt.ts b/packages/liveblocks-core/src/crdts/AbstractCrdt.ts index 17324dab311..06fb9884894 100644 --- a/packages/liveblocks-core/src/crdts/AbstractCrdt.ts +++ b/packages/liveblocks-core/src/crdts/AbstractCrdt.ts @@ -13,6 +13,8 @@ import type { SerializedCrdt } from "../protocol/StorageNode"; import type * as DevTools from "../types/DevToolsTreeNode"; import type { LiveNode, Lson } from "./Lson"; import type { StorageUpdate } from "./StorageUpdates"; +import type { ReadonlyUnacknowledgedOps } from "./UnacknowledgedOps"; +import { UnacknowledgedOps } from "./UnacknowledgedOps"; export type ApplyResult = | { reverse: Op[]; modified: StorageUpdate } @@ -53,6 +55,12 @@ export interface ManagedPool { * @returns {void} */ assertStorageIsWritable: () => void; + + /** + * Read-only view of the client's still-unacknowledged ops (sent or + * pending-send, not yet confirmed by the server). + */ + readonly unacknowledgedOps: ReadonlyUnacknowledgedOps; } export type CreateManagedPoolOptions = { @@ -79,6 +87,14 @@ export type CreateManagedPoolOptions = { * have an effect upstream. */ isStorageWritable?: () => boolean; + + /** + * Read-only view of the client's still-unacknowledged ops. Used by CRDTs + * (e.g. LiveList) to know which of their optimistic mutations the server + * hasn't confirmed yet. Defaults to an empty view (e.g. server-side pools + * that dispatch-and-flush have no optimistic state to track). + */ + unacknowledgedOps?: ReadonlyUnacknowledgedOps; }; /** @@ -92,6 +108,7 @@ export function createManagedPool( getCurrentConnectionId, onDispatch, isStorageWritable = () => true, + unacknowledgedOps = new UnacknowledgedOps(), } = options; let clock = 0; @@ -124,6 +141,8 @@ export function createManagedPool( ); } }, + + unacknowledgedOps, }; } @@ -354,8 +373,19 @@ export abstract class AbstractCrdt { this.#pool = pool; } - /** @internal */ - abstract _attachChild(op: CreateOp, source: OpSource): ApplyResult; + /** + * @internal + * `fromSnapshot` is set when the op is part of a full-state snapshot + * reconstruction (the reconnect reconcile) rather than a live incremental op. + * Only LiveList uses it, to suppress its optimistic push tail-bump: the bump + * predicts where the server will place pending pushes, but a snapshot already + * holds the final positions, so there's nothing to predict. + */ + abstract _attachChild( + op: CreateOp, + source: OpSource, + fromSnapshot?: boolean + ): ApplyResult; /** @internal */ _detach(): void { diff --git a/packages/liveblocks-core/src/crdts/LiveList.ts b/packages/liveblocks-core/src/crdts/LiveList.ts index e24673b2512..9d1d260e62a 100644 --- a/packages/liveblocks-core/src/crdts/LiveList.ts +++ b/packages/liveblocks-core/src/crdts/LiveList.ts @@ -47,12 +47,10 @@ function childNodeLt(a: LiveNode, b: LiveNode): boolean { export class LiveList extends AbstractCrdt { #items: SortedList; #implicitlyDeletedItems: WeakSet; - #unacknowledgedSets: Map; constructor(items: TItem[]) { super(); this.#implicitlyDeletedItems = new WeakSet(); - this.#unacknowledgedSets = new Map(); const nodes: LiveNode[] = []; let lastPos: Pos | undefined; @@ -93,12 +91,13 @@ export class LiveList extends AbstractCrdt { /** * @internal - * This function assumes that the resulting ops will be sent to the server if they have an 'opId' - * so we mutate _unacknowledgedSets to avoid potential flickering - * https://github.com/liveblocks/liveblocks/pull/1177 + * Serializes this list (and its children) into Create ops. Each child's + * create is tagged with the "set" intent (in the loop below) so that a list + * created and immediately mutated doesn't transiently re-show its initial + * items (flicker, https://github.com/liveblocks/liveblocks/pull/1177). * - * This is quite unintuitive and should disappear as soon as - * we introduce an explicit LiveList.Set operation + * This is quite unintuitive and should disappear as soon as we introduce an + * explicit LiveList.Set operation. */ _toOps(parentId: string, parentKey: string): CreateOp[] { if (this._id === undefined) { @@ -117,9 +116,13 @@ export class LiveList extends AbstractCrdt { for (const item of this.#items) { const parentKey = item._getParentKeyOrThrow(); - const childOps = HACK_addIntentAndDeletedIdToOperation( + // Tag each child's create with "set" (no deletedId, since nothing + // specific is being replaced). This routes the ack through the set path + // so a list created and immediately mutated doesn't transiently re-show + // its initial items (to avoid flicker, see PR 1177). + const childOps = addIntentToRootOp( item._toOps(this._id, parentKey), - undefined + "set" ); for (const childOp of childOps) { ops.push(childOp); @@ -167,6 +170,30 @@ export class LiveList extends AbstractCrdt { ); } + /** + * The opId of this list's still-unacknowledged "set" op at the given position, + * or undefined if none. Derived from the room's unacknowledgedOps (the single + * source of truth) rather than tracked in a per-instance map. The pool's + * position index already scopes to this list's (parentId, position); the last + * match wins, matching the original last-write-wins map semantics. + */ + #unacknowledgedSetOpIdAt(position: string): string | undefined { + if (this._pool === undefined || this._id === undefined) { + return undefined; + } + + let opId: string | undefined; + for (const op of this._pool.unacknowledgedOps.getByParentIdAndKey( + this._id, + position + )) { + if (op.intent === "set") { + opId = op.opId; + } + } + return opId; + } + /** @internal */ _attach(id: string, pool: ManagedPool): void { super._attach(id, pool); @@ -282,16 +309,19 @@ export class LiveList extends AbstractCrdt { delta.push(deletedDelta); } - const unacknowledgedOpId = this.#unacknowledgedSets.get(op.parentKey); - - if (unacknowledgedOpId !== undefined) { - if (unacknowledgedOpId !== op.opId) { - return delta.length === 0 - ? { modified: false } - : { modified: makeUpdate(this, delta), reverse: [] }; - } else { - this.#unacknowledgedSets.delete(op.parentKey); - } + // If a *different* set op is still pending at this position, our optimistic + // state is newer than this (now-stale) ack, so keep ours and ignore it. + // (Nothing to clear on a match: the room already removed op.opId from + // unacknowledgedOps before dispatching this ack, so this lookup returns + // undefined for the op being acked and only ever surfaces a *newer* pending + // set. This assumes acks for a single position arrive in dispatch order, + // which holds: one client sends them sequentially and the server preserves + // that order.) + const unacknowledgedOpId = this.#unacknowledgedSetOpIdAt(op.parentKey); + if (unacknowledgedOpId !== undefined && unacknowledgedOpId !== op.opId) { + return delta.length === 0 + ? { modified: false } + : { modified: makeUpdate(this, delta), reverse: [] }; } const indexOfItemWithSamePosition = this._indexOfPosition(op.parentKey); @@ -399,7 +429,7 @@ export class LiveList extends AbstractCrdt { return result.modified.updates[0]; } - #applyRemoteInsert(op: CreateOp): ApplyResult { + #applyRemoteInsert(op: CreateOp, fromSnapshot: boolean): ApplyResult { if (this._pool === undefined) { throw new Error("Can't attach child if managed pool is not present"); } @@ -415,13 +445,106 @@ export class LiveList extends AbstractCrdt { const { newItem, newIndex } = this.#createAttachItemAndSort(op, key); - // TODO: add move update? + // A new remote sibling just landed among our items, so our still-unacked + // pushes may need bumping back to the tail. This is the only place that + // bumps: a remote insert or push is the only op that drops a *new* sibling + // into the list. Remote sets/moves are computed by the other client against + // a view that excludes our still-unacked pushes, so they can't address a + // position inside our pending tail block. + // + // The bump is a purely-local, live-only anti-flicker prediction of where + // the server will place things. While reconstructing from a server snapshot + // (reconnect reconcile) we already have the answer, so we don't predict: + // bumping there would override the snapshot's positions with a guess, and + // the diff carries no corrective op to undo it. + const bumpDeltas = fromSnapshot ? [] : this.#bumpUnackedPushesAbove(key); + return { - modified: makeUpdate(this, [insertDelta(newIndex, newItem)]), + modified: makeUpdate(this, [ + insertDelta(newIndex, newItem), + ...bumpDeltas, + ]), reverse: [], }; } + /** + * This list's own still-unacknowledged pushed items (their `intent: "push"` + * Create op is still pending in the room's unacknowledgedOps). Derived from + * the single source of truth, so an item drops out the instant its op is + * acked, with no per-instance membership to leak. Yielded in push order. + * + * Restricted to items currently in `#items`: a pushed node whose op is still + * pending may have been pulled out of the list (e.g. implicitly deleted by a + * remote set, or removed by an undo) while still living in the pool, and such + * a node must not be repositioned. + */ + *#unackedPushNodes(): Iterable { + if (this._pool === undefined || this._id === undefined) { + return; + } + + for (const op of this._pool.unacknowledgedOps.getByParentId(this._id)) { + if (op.intent !== "push") { + continue; + } + const node = this._pool.getNode(op.id); + if (node !== undefined && this.#items.includes(node)) { + yield node; + } + } + } + + /** + * Optimistic no-flip for pushed items. When a remote op lands at or below my + * still-unacked pushed items, those items must end up *after* it: FIFO plus + * the room's serial processing guarantee the remote was processed first, so + * my unacked pushes belong behind it. Re-chain the whole unacked-push block, + * in push order, to sit after the highest confirmed sibling, so it keeps + * rendering as a contiguous tail instead of getting interleaved. Local-only; + * the real acks overwrite these keys with the (identical) server keys. + */ + #bumpUnackedPushesAbove(remoteKey: Pos): LiveListUpdateDelta[] { + const pending = new Set(this.#unackedPushNodes()); + if (pending.size === 0) { + return []; + } + + // Only bump when the remote intruded into (at or below) the pending block. + // If it sorts entirely below, the block already renders above it. + let minPending: Pos | undefined; + for (const node of pending) { + const pos = node._parentPos; + if (minPending === undefined || pos < minPending) { + minPending = pos; + } + } + if (remoteKey < nn(minPending)) { + return []; + } + + // Highest confirmed (non-pending) key. `#items` is sorted ascending, so the + // last non-pending item we see is the max. + let base: Pos | undefined; + for (const item of this.#items) { + if (!pending.has(item)) { + base = item._parentPos; + } + } + + const deltas: LiveListUpdateDelta[] = []; + for (const node of pending) { + const previousIndex = this.#items.findIndex((item) => item === node); + base = makePosition(base); + this.#updateItemPosition(node, base); + const index = this.#items.findIndex((item) => item === node); + if (index !== previousIndex) { + deltas.push(moveDelta(previousIndex, index, node)); + } + } + return deltas; + } + #applyInsertAck(op: CreateOp): ApplyResult { const existingItem = this.#items.find((item) => item._id === op.id); const key = asPos(op.parentKey); @@ -528,8 +651,6 @@ export class LiveList extends AbstractCrdt { return { modified: false }; } - this.#unacknowledgedSets.set(key, nn(op.opId)); - const indexOfItemWithSameKey = this._indexOfPosition(key); child._attach(id, nn(this._pool)); @@ -546,8 +667,9 @@ export class LiveList extends AbstractCrdt { this.#items.remove(existingItem); this.#items.add(child); - const reverse = HACK_addIntentAndDeletedIdToOperation( + const reverse = addIntentToRootOp( existingItem._toOps(nn(this._id), key), + "set", op.id ); @@ -579,7 +701,11 @@ export class LiveList extends AbstractCrdt { } /** @internal */ - _attachChild(op: CreateOp, source: OpSource): ApplyResult { + _attachChild( + op: CreateOp, + source: OpSource, + fromSnapshot: boolean = false + ): ApplyResult { if (this._pool === undefined) { throw new Error("Can't attach child if managed pool is not present"); } @@ -596,7 +722,7 @@ export class LiveList extends AbstractCrdt { } } else { if (source === OpSource.THEIRS) { - result = this.#applyRemoteInsert(op); + result = this.#applyRemoteInsert(op, fromSnapshot); } else if (source === OpSource.OURS) { result = this.#applyInsertAck(op); } else { @@ -855,8 +981,7 @@ export class LiveList extends AbstractCrdt { * @param element The element to add to the end of the LiveList. */ push(element: TItem): void { - this._pool?.assertStorageIsWritable(); - return this.insert(element, this.length); + return this.#injectAt(element, this.length, "push"); } /** @@ -865,6 +990,16 @@ export class LiveList extends AbstractCrdt { * @param index The index at which you want to insert the element. */ insert(element: TItem, index: number): void { + return this.#injectAt(element, index, "insert"); + } + + /** + * Shared implementation of `insert` and `push`. A `"push"` intent leaves the + * client-computed position untouched (so optimistic rendering is unchanged), + * but tags the Op so the server appends it to the true end of the list + * instead of resolving its position against the client's stale view. + */ + #injectAt(element: TItem, index: number, intent: "insert" | "push"): void { this._pool?.assertStorageIsWritable(); if (index < 0 || index > this.#items.length) { throw new Error( @@ -886,8 +1021,9 @@ export class LiveList extends AbstractCrdt { const id = this._pool.generateId(); value._attach(id, this._pool); + const ops = value._toOpsWithOpId(this._id, position, this._pool); this._pool.dispatch( - value._toOpsWithOpId(this._id, position, this._pool), + intent === "push" ? addIntentToRootOp(ops, "push") : ops, [{ type: OpCode.DELETE_CRDT, id }], new Map>([ [this._id, makeUpdate(this, [insertDelta(index, value)])], @@ -1085,13 +1221,14 @@ export class LiveList extends AbstractCrdt { const storageUpdates = new Map>(); storageUpdates.set(this._id, makeUpdate(this, [setDelta(index, value)])); - const ops = HACK_addIntentAndDeletedIdToOperation( + const ops = addIntentToRootOp( value._toOpsWithOpId(this._id, position, this._pool), + "set", existingId ); - this.#unacknowledgedSets.set(position, nn(ops[0].opId)); - const reverseOps = HACK_addIntentAndDeletedIdToOperation( + const reverseOps = addIntentToRootOp( existingItem._toOps(this._id, position), + "set", id ); @@ -1345,23 +1482,33 @@ function moveDelta( } /** - * This function is only temporary. - * As soon as we refactor the operations structure, - * serializing a LiveStructure should not know anything about intent + * Tags the root op of a serialized CreateOp sequence with an `intent` ("set" or + * "push") and an optional `deletedId`, telling the server how to resolve the + * new node's position in its parent list: replace an existing item ("set") or + * append to the true end ("push"). Only the root is tagged; see the note in the + * body for why. + * + * By default, no explicit intent means a regular insert. */ -function HACK_addIntentAndDeletedIdToOperation( +function addIntentToRootOp(ops: T[], intent: "push"): T[]; +function addIntentToRootOp( ops: T[], - deletedId: string | undefined + intent: "set", + deletedId?: string +): T[]; +function addIntentToRootOp( + ops: T[], + intent: "set" | "push", + deletedId?: string ): T[] { return ops.map((op, index) => { if (index === 0) { - // NOTE: Only patch the first Op here + // NOTE: Only the *first* Op is patched. `_toOps`/`_toOpsWithOpId` emit + // `ops[0]` for the value itself (whose parent is the list), followed by + // ops for that value's descendants (whose parent is the new node, not + // the list). const firstOp = op; - return { - ...firstOp, - intent: "set", - deletedId, - }; + return { ...firstOp, intent, deletedId }; } else { return op; } diff --git a/packages/liveblocks-core/src/crdts/LiveObject.ts b/packages/liveblocks-core/src/crdts/LiveObject.ts index b294741a08d..f8a4339f92e 100644 --- a/packages/liveblocks-core/src/crdts/LiveObject.ts +++ b/packages/liveblocks-core/src/crdts/LiveObject.ts @@ -33,6 +33,7 @@ import { deserializeToLson, isLiveNode, isLiveStructure, + liveNodeToLson, } from "./liveblocks-helpers"; import type { SyncConfig } from "./reconcile"; import { reconcileLiveObject } from "./reconcile"; @@ -314,6 +315,7 @@ export class LiveObject extends AbstractCrdt { const id = nn(this._id); const parentKey = nn(child._parentKey); const reverse = child._toOps(id, parentKey); + const deletedItem = liveNodeToLson(child); for (const [key, value] of this.#synced) { if (value === child) { @@ -328,7 +330,7 @@ export class LiveObject extends AbstractCrdt { node: this, type: "LiveObject", updates: { - [parentKey]: { type: "delete" }, + [parentKey]: { type: "delete", deletedItem }, } as { [K in keyof O]: UpdateDelta }, }; diff --git a/packages/liveblocks-core/src/crdts/UnacknowledgedOps.ts b/packages/liveblocks-core/src/crdts/UnacknowledgedOps.ts new file mode 100644 index 00000000000..9036ceee71d --- /dev/null +++ b/packages/liveblocks-core/src/crdts/UnacknowledgedOps.ts @@ -0,0 +1,147 @@ +import type { ClientWireCreateOp, ClientWireOp } from "../protocol/Op"; +import { isCreateOp } from "../protocol/Op"; + +/** + * A node's position in storage, encoded as `${parentId}\n${parentKey}`. Used as + * the key of the position index, so that all pending Create ops targeting the + * same spot bucket together. + */ +type PositionKey = `${string}\n${string}`; + +/** + * Read-only query surface over {@link UnacknowledgedOps}, handed to CRDTs so + * they can look up their own still-pending Create ops without being able to + * mutate the set (only the room adds/acks). + */ +export interface ReadonlyUnacknowledgedOps { + /** Still-unacknowledged Create ops whose `parentId` is the given one. */ + getByParentId(parentId: string): Iterable; + + /** + * Still-unacknowledged Create ops whose `parentId` and `parentKey` are both + * the given ones (i.e. targeting one exact position). + */ + getByParentIdAndKey( + parentId: string, + parentKey: string + ): Iterable; +} + +/** + * The client's still-unacknowledged ops. + * + * Maintains three indexes that stay in lockstep (the whole point of keeping + * this in one place): + * + * - `#byOpId`: the primary record, `opId -> op`. + * - `#createOpsByPosition`: `position -> (opId -> Create op)`. Finds the pending + * Create ops at one exact (parentId, parentKey) position in O(1), e.g. to + * resolve set acks. Nested because more than one pending Create op can target + * the same position (two rapid `set()`s at one index); keying the inner map + * by opId keeps it in lockstep with `#byOpId` under any ack order. + * - `#createOpsByParent`: `parentId -> (opId -> Create op)`. Finds all pending + * Create ops under one parent node in O(1), regardless of position, e.g. a + * list's own optimistically-pushed items. + * + * Only Create ops carry a parent/position, so the two secondary indexes hold + * exactly those. + */ +export class UnacknowledgedOps implements ReadonlyUnacknowledgedOps { + // opId -> op + #byOpId: Map = new Map(); + // position -> (opId -> Create op) + #createOpsByPosition: Map> = + new Map(); + // parentId -> (opId -> Create op) + #createOpsByParent: Map> = new Map(); + + #posKey(parentId: string, parentKey: string): PositionKey { + return `${parentId}\n${parentKey}`; + } + + get size(): number { + return this.#byOpId.size; + } + + /** + * Mark the given Op as still unacknowledged. + */ + add(op: ClientWireOp): void { + this.#byOpId.set(op.opId, op); + + if (isCreateOp(op)) { + const posKey = this.#posKey(op.parentId, op.parentKey); + let atPosition = this.#createOpsByPosition.get(posKey); + if (atPosition === undefined) { + atPosition = new Map(); + this.#createOpsByPosition.set(posKey, atPosition); + } + atPosition.set(op.opId, op); + + let inParent = this.#createOpsByParent.get(op.parentId); + if (inParent === undefined) { + inParent = new Map(); + this.#createOpsByParent.set(op.parentId, inParent); + } + inParent.set(op.opId, op); + } + } + + /** + * Drop the op with the given opId from the set, because the server has + * acknowledged it (confirmed our own op, or signalled it was seen but + * ignored). + */ + delete(opId: string): void { + const op = this.#byOpId.get(opId); + if (op === undefined) { + return; + } + + this.#byOpId.delete(opId); + + if (isCreateOp(op)) { + const posKey = this.#posKey(op.parentId, op.parentKey); + const atPosition = this.#createOpsByPosition.get(posKey); + atPosition?.delete(opId); + if (atPosition !== undefined && atPosition.size === 0) { + this.#createOpsByPosition.delete(posKey); + } + + const inParent = this.#createOpsByParent.get(op.parentId); + inParent?.delete(opId); + if (inParent !== undefined && inParent.size === 0) { + this.#createOpsByParent.delete(op.parentId); + } + } + } + + /** + * The still-unacknowledged Create ops with the given `parentId` and + * `parentKey` (targeting one exact position), in dispatch order. O(1) lookup. + * Empty if none. + */ + getByParentIdAndKey( + parentId: string, + parentKey: string + ): Iterable { + return ( + this.#createOpsByPosition + .get(this.#posKey(parentId, parentKey)) + ?.values() ?? [] + ); + } + + /** + * The still-unacknowledged Create ops with the given `parentId` (across all + * positions), in dispatch order. O(1) lookup. Empty if none. + */ + getByParentId(parentId: string): Iterable { + return this.#createOpsByParent.get(parentId)?.values() ?? []; + } + + /** All still-unacknowledged ops, in dispatch order. */ + values(): IterableIterator { + return this.#byOpId.values(); + } +} diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts index e34e4221464..6ee2b0ba7db 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts @@ -60,6 +60,25 @@ describe("LiveList", () => { list.clear(); expect(list.toJSON()).toEqual([]); }); + + test("mutations update local state when not attached", () => { + const list = new LiveList(["a", "b"]); + + list.push("c"); + expect(list.toJSON()).toEqual(["a", "b", "c"]); + + list.insert("x", 1); + expect(list.toJSON()).toEqual(["a", "x", "b", "c"]); + + list.move(0, 2); + expect(list.toJSON()).toEqual(["x", "b", "a", "c"]); + + list.set(0, "y"); + expect(list.toJSON()).toEqual(["y", "b", "a", "c"]); + + list.delete(3); + expect(list.toJSON()).toEqual(["y", "b", "a"]); + }); }); describe("deserialization", () => { diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts index 21657019233..4acb79aac7f 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts @@ -129,8 +129,13 @@ describe("LiveList edge cases", () => { }, ]); + // x0/x1 are this client's own still-unacked pushes, so the arriving + // remote y1 (processed first by the server) sorts before them. The + // pending push-tail stays contiguous after y1 rather than being + // interleaved into ["y0", "x0", "y1", "x1"] and then flipping. The + // SET_PARENT_KEY below confirms the server places x0 after y0/y1 too. expectStorage({ - items: ["y0", "x0", "y1", "x1"], + items: ["y0", "y1", "x0", "x1"], }); simulateRemoteOps(room, [ @@ -158,6 +163,52 @@ describe("LiveList edge cases", () => { }); }); + test("push-tail bump skips a pending push that left the list", async () => { + const { room, root, expectStorage } = await prepareIsolatedStorageTest<{ + items: LiveList; + }>( + [createSerializedRoot(), createSerializedList("0:1", "root", "items")], + 1 + ); + + const items = root.get("items"); + + // This client pushes x0 (id 1:0); its CREATE op stays unacknowledged. + items.push("x0"); + expectStorage({ items: ["x0"] }); + + // A remote "set" lands at x0's position with a mismatching deletedId, so + // x0 is treated as a conflict: pulled out of the list but kept in the pool + // (an "implicitly deleted" item) while its push op is still pending. + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "2:0", + parentId: "0:1", + parentKey: FIRST_POSITION, + data: "y0", + intent: "set", + deletedId: "0:404", // not x0 (1:0) => conflict => x0 implicitly deleted + }, + ]); + expectStorage({ items: ["y0"] }); + + // A further remote insert triggers the push-tail bump. x0 is still an + // unacked push living in the pool, but no longer in the list, so it must + // be skipped rather than repositioned (which threw "Cannot reposition + // item that is not in the list"). + simulateRemoteOps(room, [ + { + type: OpCode.CREATE_REGISTER, + id: "2:1", + parentId: "0:1", + parentKey: SECOND_POSITION, + data: "z0", + }, + ]); + expectStorage({ items: ["y0", "z0"] }); + }); + test("list conflicts with offline", async () => { const { room, root, expectStorage, wss } = await prepareIsolatedStorageTest<{ items: LiveList }>( @@ -751,6 +802,7 @@ describe("LiveList edge cases", () => { parentKey: FIRST_POSITION, data: "B", intent: "set", + deletedId: "0:1", }, ]); @@ -784,6 +836,7 @@ describe("LiveList edge cases", () => { parentKey: FIRST_POSITION, data: "B", intent: "set", + deletedId: "0:1", }, ]); @@ -828,6 +881,7 @@ describe("LiveList edge cases", () => { parentKey: FIRST_POSITION, data: "B", intent: "set", + deletedId: "0:1", }, ]); @@ -862,6 +916,7 @@ describe("LiveList edge cases", () => { parentKey: FIRST_POSITION, data: "B", intent: "set", + deletedId: "0:1", }, ]); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts index a64403dc35b..647f26d722c 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts @@ -144,7 +144,7 @@ describe("LiveObject edge cases", () => { modified: { node: obj, type: "LiveObject", - updates: { b: { type: "delete" } }, + updates: { b: { type: "delete", deletedItem: secondItem } }, }, reverse: [ { diff --git a/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts b/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts index 2728bf97cb5..08492cb09ab 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts @@ -246,8 +246,140 @@ describe("getTreesDiffOperations", () => { id: "0:2", data: { c: 1 }, }, + { + type: OpCode.DELETE_OBJECT_KEY, + id: "0:2", + key: "b", + }, ]); }); + + test("liveObject replacing a non-object node of the same id", () => { + const currentItems: NodeMap = new Map([ + ["root", { type: CrdtType.OBJECT, data: {} }], + ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }], + [ + "0:2", + { + type: CrdtType.REGISTER, + parentId: "0:1", + parentKey: FIRST_POSITION, + data: "A", + }, + ], + ]); + + const newItems: NodeMap = new Map([ + ["root", { type: CrdtType.OBJECT, data: {} }], + ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }], + [ + "0:2", + { + type: CrdtType.OBJECT, + parentId: "0:1", + parentKey: FIRST_POSITION, + data: { a: 1 }, + }, + ], + ]); + + const ops = getTreesDiffOperations(currentItems, newItems); + + expect(ops).toEqual([ + { + type: OpCode.UPDATE_OBJECT, + id: "0:2", + data: { a: 1 }, + }, + ]); + }); + + test("new liveList", () => { + const currentItems: NodeMap = new Map([ + ["root", { type: CrdtType.OBJECT, data: {} }], + ]); + + const newItems = new Map(currentItems); + newItems.set("0:1", { + type: CrdtType.LIST, + parentId: "root", + parentKey: "items", + }); + + const ops = getTreesDiffOperations(currentItems, newItems); + + expect(ops).toEqual([ + { + type: OpCode.CREATE_LIST, + id: "0:1", + parentId: "root", + parentKey: "items", + }, + ]); + }); + + test("new liveMap", () => { + const currentItems: NodeMap = new Map([ + ["root", { type: CrdtType.OBJECT, data: {} }], + ]); + + const newItems = new Map(currentItems); + newItems.set("0:1", { + type: CrdtType.MAP, + parentId: "root", + parentKey: "map", + }); + + const ops = getTreesDiffOperations(currentItems, newItems); + + expect(ops).toEqual([ + { + type: OpCode.CREATE_MAP, + id: "0:1", + parentId: "root", + parentKey: "map", + }, + ]); + }); + + test("new liveObject", () => { + const currentItems: NodeMap = new Map([ + ["root", { type: CrdtType.OBJECT, data: {} }], + ]); + + const newItems = new Map(currentItems); + newItems.set("0:1", { + type: CrdtType.OBJECT, + parentId: "root", + parentKey: "item", + data: { a: 1 }, + }); + + const ops = getTreesDiffOperations(currentItems, newItems); + + expect(ops).toEqual([ + { + type: OpCode.CREATE_OBJECT, + id: "0:1", + parentId: "root", + parentKey: "item", + data: { a: 1 }, + }, + ]); + }); + + test("new liveObject without a parent throws", () => { + const currentItems: NodeMap = new Map([ + ["root", { type: CrdtType.OBJECT, data: {} }], + ]); + + const newItems = new Map(currentItems); + newItems.set("0:1", { type: CrdtType.OBJECT, data: { a: 1 } }); + + expect(() => getTreesDiffOperations(currentItems, newItems)).toThrow( + "Internal error. Cannot serialize storage root into an operation" + ); + }); }); describe("toPlainLson", () => { diff --git a/packages/liveblocks-core/src/crdts/liveblocks-helpers.ts b/packages/liveblocks-core/src/crdts/liveblocks-helpers.ts index 841a0f12d8f..a9a7280cf40 100644 --- a/packages/liveblocks-core/src/crdts/liveblocks-helpers.ts +++ b/packages/liveblocks-core/src/crdts/liveblocks-helpers.ts @@ -144,6 +144,101 @@ export function lsonToLiveNode(value: Lson): LiveNode { } } +/** + * Serializes every node currently in the pool into a flat, human-readable + * table: one line per node with its id, parent id, parent key, and value. The + * parent key is the fractional position key for list items, or the field/map + * key otherwise. + * + * Unlike `.toJSON()`, this also surfaces nodes that are still in the pool but + * detached from any parent (orphaned, or pending and not yet acknowledged), + * which is exactly the kind of discrepancy a convergence bug leaves behind. + * Intended for debugging only. + */ +export function dumpPool(pool: ManagedPool): string { + const rows = Array.from(pool.nodes.values(), (node) => { + const parent = node.parent; + const parentId = + parent.type === "HasParent" + ? (parent.node._id ?? "?") + : parent.type === "Orphaned" + ? "" + : "-"; + + let value: string; + if (node instanceof LiveRegister) { + value = stringify(node.data); + } else if (node instanceof LiveList) { + value = ""; + } else if (node instanceof LiveMap) { + value = ""; + } else { + value = ""; + } + + return { id: nn(node._id), parentId, key: node._parentKey ?? "", value }; + }); + + // Group children of the same parent together, ordered by key. Compare keys + // by raw string order, matching how the CRDT itself orders positions + // (childNodeLt: a._parentPos < b._parentPos). + rows.sort((a, b) => { + if (a.parentId !== b.parentId) return a.parentId < b.parentId ? -1 : 1; + if (a.key !== b.key) return a.key < b.key ? -1 : 1; + return 0; + }); + + return rows + .map( + (r) => ` ${r.id} parent=${r.parentId} key=${r.key || "—"} ${r.value}` + ) + .join("\n"); +} + +/** + * Deep-equality check for two Json values. Short-circuits on the first + * difference and allocates nothing: the cheap `===` settles every primitive, + * and nested arrays/objects are compared by traversal (key-by-key, so key order + * is irrelevant). + */ +function isJsonEq(a: Json | undefined, b: Json | undefined): boolean { + if (a === b) { + return true; + } + if ( + typeof a !== "object" || + a === null || + typeof b !== "object" || + b === null + ) { + return false; + } + + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!isJsonEq(a[i], b[i])) { + return false; + } + } + return true; + } + + // Both are plain objects: same number of keys, and every key matches. + const aKeys = Object.keys(a); + if (aKeys.length !== Object.keys(b).length) { + return false; + } + for (const key of aKeys) { + if (!isJsonEq(a[key], b[key])) { + return false; + } + } + return true; +} + /** * Computes the operations needed to transform one NodeMap into another. * @@ -184,15 +279,37 @@ export function getTreesDiffOperations( const currentCrdt = currentItems.get(id); if (currentCrdt) { if (crdt.type === CrdtType.OBJECT) { - if ( - currentCrdt.type !== CrdtType.OBJECT || - stringify(crdt.data) !== stringify(currentCrdt.data) - ) { - ops.push({ - type: OpCode.UPDATE_OBJECT, - id, - data: crdt.data, - }); + if (currentCrdt.type !== CrdtType.OBJECT) { + // Node changed into an object; send its full data. + ops.push({ type: OpCode.UPDATE_OBJECT, id, data: crdt.data }); + } else { + // Emit an UPDATE_OBJECT carrying only the keys that were added or + // whose value changed. Sending the full data would re-notify keys + // that did not actually change. + const changed = new Map(); + for (const key of Object.keys(crdt.data)) { + const value = crdt.data[key]; + if ( + value !== undefined && + !isJsonEq(value, currentCrdt.data[key]) + ) { + changed.set(key, value); + } + } + if (changed.size > 0) { + ops.push({ + type: OpCode.UPDATE_OBJECT, + id, + data: Object.fromEntries(changed), + }); + } + // Keys present locally but absent from the snapshot must be deleted + // explicitly, otherwise they linger and the two clients diverge. + for (const key of Object.keys(currentCrdt.data)) { + if (!(key in crdt.data)) { + ops.push({ type: OpCode.DELETE_OBJECT_KEY, id, key }); + } + } } } if (crdt.parentKey !== currentCrdt.parentKey) { diff --git a/packages/liveblocks-core/src/lib/SortedList.ts b/packages/liveblocks-core/src/lib/SortedList.ts index f86be87758f..5636cb12bb7 100644 --- a/packages/liveblocks-core/src/lib/SortedList.ts +++ b/packages/liveblocks-core/src/lib/SortedList.ts @@ -209,6 +209,25 @@ export class SortedList { return this.#data.length; } + /** + * Whether the given value is present, by identity. O(log n) plus the length + * of any run of items that share its sort key (normally 1). Bisects on the + * value's own key, so it only finds values sitting at their sorted position, + * which is true for any item currently in the list. + */ + includes(value: T): boolean { + for ( + let i = bisectRight(this.#data, value, this.#lt) - 1; + i >= 0 && !this.#lt(this.#data[i], value); + i-- + ) { + if (this.#data[i] === value) { + return true; + } + } + return false; + } + *filter(predicate: (value: T) => boolean): IterableIterator { for (const item of this.#data) { if (predicate(item)) { diff --git a/packages/liveblocks-core/src/lib/__tests__/SortedList.test.ts b/packages/liveblocks-core/src/lib/__tests__/SortedList.test.ts index 73c403f98ba..77636a9e8c8 100644 --- a/packages/liveblocks-core/src/lib/__tests__/SortedList.test.ts +++ b/packages/liveblocks-core/src/lib/__tests__/SortedList.test.ts @@ -618,4 +618,56 @@ describe("SortedList", () => { ) ); }); + + describe("includes", () => { + test("empty list", () => { + expect(SortedList.from([], asc).includes(13)).toBe(false); + }); + + test("present / absent by value (primitives)", () => { + const s = SortedList.from([1, 3, 5, 7], asc); + expect(s.includes(1)).toBe(true); // first + expect(s.includes(7)).toBe(true); // last + expect(s.includes(5)).toBe(true); // middle + expect(s.includes(4)).toBe(false); // gap + expect(s.includes(0)).toBe(false); // below min + expect(s.includes(9)).toBe(false); // above max + }); + + test("matches by identity, not by sort key", () => { + const lt = (a: { k: number }, b: { k: number }) => a.k < b.k; + const a = { k: 1 }; + const b = { k: 2 }; + const s = SortedList.from([a, b], lt); + + expect(s.includes(a)).toBe(true); + expect(s.includes(b)).toBe(true); + // Same sort key, different object: not present. + expect(s.includes({ k: 1 })).toBe(false); + // The crux of the bump fix: a node removed from the list (but whose stale + // key still collides with a live one) must report absent. + const removed = { k: 2 }; + expect(s.includes(removed)).toBe(false); + }); + + test("finds the exact object within a run of equal keys", () => { + const lt = (a: { k: number }, b: { k: number }) => a.k < b.k; + const dupes = [{ k: 5 }, { k: 5 }, { k: 5 }]; + const s = SortedList.from([{ k: 1 }, ...dupes, { k: 9 }], lt); + + for (const d of dupes) { + expect(s.includes(d)).toBe(true); + } + expect(s.includes({ k: 5 })).toBe(false); // impostor with the same key + }); + + test("reflects add / remove", () => { + const s = SortedList.from([], asc); + expect(s.includes(42)).toBe(false); + s.add(42); + expect(s.includes(42)).toBe(true); + s.remove(42); + expect(s.includes(42)).toBe(false); + }); + }); }); diff --git a/packages/liveblocks-core/src/protocol/Op.ts b/packages/liveblocks-core/src/protocol/Op.ts index 46beb4b9026..c8c3c52b45a 100644 --- a/packages/liveblocks-core/src/protocol/Op.ts +++ b/packages/liveblocks-core/src/protocol/Op.ts @@ -53,43 +53,43 @@ export type UpdateObjectOp = { export type CreateObjectOp = { readonly opId?: string; readonly id: string; - readonly intent?: "set"; - readonly deletedId?: string; readonly type: OpCode.CREATE_OBJECT; readonly parentId: string; readonly parentKey: string; readonly data: JsonObject; + readonly intent?: "set" | "push"; + readonly deletedId?: string; }; export type CreateListOp = { readonly opId?: string; readonly id: string; - readonly intent?: "set"; - readonly deletedId?: string; readonly type: OpCode.CREATE_LIST; readonly parentId: string; readonly parentKey: string; + readonly intent?: "set" | "push"; + readonly deletedId?: string; }; export type CreateMapOp = { readonly opId?: string; readonly id: string; - readonly intent?: "set"; - readonly deletedId?: string; readonly type: OpCode.CREATE_MAP; readonly parentId: string; readonly parentKey: string; + readonly intent?: "set" | "push"; + readonly deletedId?: string; }; export type CreateRegisterOp = { readonly opId?: string; readonly id: string; - readonly intent?: "set"; - readonly deletedId?: string; readonly type: OpCode.CREATE_REGISTER; readonly parentId: string; readonly parentKey: string; readonly data: Json; + readonly intent?: "set" | "push"; + readonly deletedId?: string; }; export type DeleteCrdtOp = { @@ -115,6 +115,15 @@ export function isIgnoredOp(op: ServerWireOp): op is IgnoredOp { return op.type === OpCode.DELETE_CRDT && op.id === "ACK"; } +export function isCreateOp(op: O): op is O & CreateOp { + return ( + op.type === OpCode.CREATE_OBJECT || + op.type === OpCode.CREATE_REGISTER || + op.type === OpCode.CREATE_MAP || + op.type === OpCode.CREATE_LIST + ); +} + export type SetParentKeyOp = { readonly opId?: string; readonly id: string; diff --git a/packages/liveblocks-core/src/room.ts b/packages/liveblocks-core/src/room.ts index 96315044d6c..8d96aa934d8 100644 --- a/packages/liveblocks-core/src/room.ts +++ b/packages/liveblocks-core/src/room.ts @@ -8,6 +8,7 @@ import type { ApplyResult, ManagedPool } from "./crdts/AbstractCrdt"; import { createManagedPool, OpSource } from "./crdts/AbstractCrdt"; import { cloneLson, + dumpPool, getTreesDiffOperations, isLiveList, isLiveNode, @@ -17,6 +18,7 @@ import { import { LiveObject } from "./crdts/LiveObject"; import type { LiveStructure, LsonObject } from "./crdts/Lson"; import type { StorageCallback, StorageUpdate } from "./crdts/StorageUpdates"; +import { UnacknowledgedOps } from "./crdts/UnacknowledgedOps"; import type { DCM, DE, @@ -894,6 +896,13 @@ export type Room< */ reconnect(): void; + /** + * @internal + * Returns a human-readable dump of every node in this room's storage pool + * (id, parent, parent key, value). For debugging convergence issues only. + */ + _dump(): string; + /** * Returns the threads within the current room and their associated inbox notifications. * It also returns the request date that can be used for subsequent polling. @@ -1364,8 +1373,9 @@ type RoomState< } | null; // A registry of yet-unacknowledged Ops. These Ops have already been - // submitted to the server, but have not yet been acknowledged. - readonly unacknowledgedOps: Map; + // submitted to the server, but have not yet been acknowledged. Indexed both + // by opId and by (parentId, parentKey) position. See UnacknowledgedOps. + readonly unacknowledgedOps: UnacknowledgedOps; }; export type Polyfills = { @@ -1563,6 +1573,11 @@ export function createRoom< config.enableDebugLogging ); + // The single source of truth for still-unacknowledged ops. Created up front + // so the pool can hold a (read-only) reference to the same instance the room + // mutates. + const unacknowledgedOps = new UnacknowledgedOps(); + // The room's internal stateful context const context: RoomState = { buffer: { @@ -1595,6 +1610,7 @@ export function createRoom< getCurrentConnectionId, onDispatch, isStorageWritable, + unacknowledgedOps, }), root: undefined, @@ -1603,7 +1619,7 @@ export function createRoom< pausedHistory: null, activeBatch: null, - unacknowledgedOps: new Map(), + unacknowledgedOps, }; // Accumulates nodes as initial storage arrives in chunks via @@ -1875,10 +1891,20 @@ export function createRoom< currentItems.set(id, crdt._serialize()); } - // Get operations that represent the diff between 2 states. + // XXX_vincent Smell, needs a deeper refactor soon! A reconnect + // snapshot is a stream of *nodes* (the full authoritative state), but + // here we fabricate a diff of *ops* and replay it through the live + // op-apply path. That path carries live-only optimistic semantics (the + // LiveList push tail-bump, "temporary position until the backend sends + // a fix" shifts, pending-conflict resolution) that are nonsensical + // when the stream we are applying already IS the fix. The + // `fromSnapshot` flag below patches only the one leak that bit us (the + // bump); it does not address the others. The proper fix is + // a node-stream reconcile that updates the tree in place, unified with + // the `_fromItems` path used on initial load, so a node stream never + // enters the op path at all. Until then `fromSnapshot` is a stopgap. const ops = getTreesDiffOperations(currentItems, nodes); - - const result = applyRemoteOps(ops); + const result = applyRemoteOps(ops, /* fromSnapshot */ true); notify(result.updates); } else { context.root = LiveObject._fromItems( @@ -1993,20 +2019,27 @@ export function createRoom< return { opsToEmit: opsWithOpIds, reverse, updates }; } - function applyRemoteOps(ops: readonly ServerWireOp[]): { + function applyRemoteOps( + ops: readonly ServerWireOp[], + // True when `ops` reconstruct state from a server snapshot (the reconnect + // reconcile) rather than being live ops. Disables the live-only LiveList + // push tail-bump. + fromSnapshot: boolean = false + ): { // Updates to notify about afterwards updates: { storageUpdates: Map; presence: boolean; }; } { - return applyOps([], ops, /* isLocal */ false); + return applyOps([], ops, /* isLocal */ false, fromSnapshot); } function applyOps( pframes: readonly PresenceStackframe

[], ops: readonly Op[], - isLocal: boolean + isLocal: boolean, + fromSnapshot: boolean = false ): { reverse: Stackframe

[]; updates: { @@ -2061,7 +2094,7 @@ export function createRoom< source = OpSource.THEIRS; } - const applyOpResult = applyOp(op, source); + const applyOpResult = applyOp(op, source, fromSnapshot); if (applyOpResult.modified) { const nodeId = applyOpResult.modified.node._id; @@ -2098,7 +2131,11 @@ export function createRoom< }; } - function applyOp(op: Op, source: OpSource): ApplyResult { + function applyOp( + op: Op, + source: OpSource, + fromSnapshot: boolean = false + ): ApplyResult { // Explicit case to handle ignored Ops if (isIgnoredOp(op)) { return { modified: false }; @@ -2144,7 +2181,7 @@ export function createRoom< return { modified: false }; } - return parentNode._attachChild(op, source); + return parentNode._attachChild(op, source, fromSnapshot); } } } @@ -2335,14 +2372,13 @@ export function createRoom< } } - function applyAndSendOfflineOps(unackedOps: Map) { - if (unackedOps.size === 0) { + function applyAndSendOfflineOps(unackedOps: ClientWireOp[]) { + if (unackedOps.length === 0) { return; } const messages: ClientMsg[] = []; - const inOps = Array.from(unackedOps.values()); - const result = applyLocalOps(inOps); + const result = applyLocalOps(unackedOps); messages.push({ type: ClientMsgCode.UPDATE_STORAGE, ops: result.opsToEmit, @@ -2609,7 +2645,7 @@ export function createRoom< const storageOps = context.buffer.storageOperations; if (storageOps.length > 0) { for (const op of storageOps) { - context.unacknowledgedOps.set(op.opId, op); + context.unacknowledgedOps.add(op); } notifyStorageStatus(); } @@ -2944,7 +2980,7 @@ export function createRoom< } function processInitialStorage(nodes: NodeMap) { - const unacknowledgedOps = new Map(context.unacknowledgedOps); + const unacknowledgedOps = [...context.unacknowledgedOps.values()]; createOrUpdateRootFromMessage(nodes); applyAndSendOfflineOps(unacknowledgedOps); _resolveStoragePromise?.(); @@ -3780,6 +3816,11 @@ export function createRoom< connect: () => managedSocket.connect(), reconnect: () => managedSocket.reconnect(), disconnect: () => managedSocket.disconnect(), + + _dump: () => { + const n = context.pool.nodes.size; + return `Room "${roomId}" (${n} node${n === 1 ? "" : "s"}):\n${dumpPool(context.pool)}`; + }, destroy: () => { pendingFeedsRequests.forEach((request) => request.reject(new Error("Room destroyed")) diff --git a/packages/liveblocks-emails/package.json b/packages/liveblocks-emails/package.json index 784752e0812..86f5f3e7c4d 100644 --- a/packages/liveblocks-emails/package.json +++ b/packages/liveblocks-emails/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/emails", - "version": "3.19.3", + "version": "3.19.4", "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", "author": "Liveblocks Inc.", @@ -32,7 +32,7 @@ "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", "test": "vitest run", - "test:ci": "vitest run", + "test:ci": "vitest run --coverage", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/liveblocks-node-lexical/package.json b/packages/liveblocks-node-lexical/package.json index bf11452e6b5..81c263d6392 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.19.3", + "version": "3.19.4", "description": "A server-side utility that lets you modify lexical documents hosted in Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -31,7 +31,7 @@ "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", "test": "vitest run", - "test:ci": "vitest run", + "test:ci": "vitest run --coverage", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/liveblocks-node-prosemirror/package.json b/packages/liveblocks-node-prosemirror/package.json index d82debea806..bfaed895391 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.19.3", + "version": "3.19.4", "description": "A server-side utility that lets you modify prosemirror and tiptap documents hosted in Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -31,7 +31,7 @@ "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", "test": "vitest run", - "test:ci": "vitest run", + "test:ci": "vitest run --coverage", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/liveblocks-node/package.json b/packages/liveblocks-node/package.json index 675ad720619..3d9ea016550 100644 --- a/packages/liveblocks-node/package.json +++ b/packages/liveblocks-node/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node", - "version": "3.19.3", + "version": "3.19.4", "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", "author": "Liveblocks Inc.", @@ -31,7 +31,7 @@ "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", "test": "vitest run", - "test:ci": "vitest run", + "test:ci": "vitest run --coverage", "test:types": "vitest run --config ./vitest.config.typecheck.ts", "test:watch": "vitest" }, diff --git a/packages/liveblocks-react-blocknote/package.json b/packages/liveblocks-react-blocknote/package.json index 1ec985569a4..44c24b56550 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.19.3", + "version": "3.19.4", "description": "An integration of BlockNote + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -40,7 +40,7 @@ "lint:package": "publint --strict && attw --pack", "start": "pnpm run dev", "test": "vitest run", - "test:ci": "vitest run", + "test:ci": "vitest run --coverage", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/liveblocks-react-flow/package.json b/packages/liveblocks-react-flow/package.json index 2053252c1b9..ac61950232f 100644 --- a/packages/liveblocks-react-flow/package.json +++ b/packages/liveblocks-react-flow/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-flow", - "version": "3.19.3", + "version": "3.19.4", "description": "An integration of React Flow to enable collaboration and realtime cursors with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -57,8 +57,8 @@ "format": "(eslint --fix src/ || true) && stylelint --fix src/styles/ && prettier --write src/", "lint:package": "publint --strict && attw --pack && node check-node-entrypoint.mjs", "lint": "eslint src/ && stylelint src/styles/", - "test": "pnpm dlx liveblocks dev -p 1165 -c 'vitest run --coverage'", - "test:ci": "vitest run", + "test": "pnpm dlx liveblocks dev -P -c 'vitest run --coverage'", + "test:ci": "pnpm dlx liveblocks dev -P -c 'vitest run --coverage'", "test:types": "vitest run --config ./vitest.config.typecheck.ts", "test:watch": "vitest" }, diff --git a/packages/liveblocks-react-lexical/package.json b/packages/liveblocks-react-lexical/package.json index cea83905ea7..71f0545fdd1 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.19.3", + "version": "3.19.4", "description": "An integration of Lexical + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -40,7 +40,7 @@ "lint:package": "publint --strict && attw --pack", "start": "pnpm run dev", "test": "vitest run", - "test:ci": "vitest run", + "test:ci": "vitest run --coverage", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/liveblocks-react-tiptap/package.json b/packages/liveblocks-react-tiptap/package.json index 3c19dabc7bd..109dff92471 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.19.3", + "version": "3.19.4", "description": "An integration of TipTap + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -40,7 +40,7 @@ "lint:package": "publint --strict && attw --pack", "start": "pnpm run dev", "test": "vitest run", - "test:ci": "vitest run", + "test:ci": "vitest run --coverage", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/liveblocks-react-ui/package.json b/packages/liveblocks-react-ui/package.json index 745d7cce0a1..a476bc3cb99 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.19.3", + "version": "3.19.4", "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", "author": "Liveblocks Inc.", @@ -71,8 +71,8 @@ "format": "eslint --fix src/; stylelint --fix src/styles/; prettier --write src/", "lint": "eslint src/; stylelint src/styles/", "lint:package": "publint --strict && attw --pack", - "test": "pnpm dlx liveblocks dev -p 1164 -c 'vitest run'", - "test:ci": "vitest run", + "test": "pnpm dlx liveblocks dev -P -c 'vitest run'", + "test:ci": "pnpm dlx liveblocks dev -P -c 'vitest run --coverage'", "test:types": "vitest run --config ./vitest.config.typecheck.ts", "test:watch": "vitest" }, diff --git a/packages/liveblocks-react/package.json b/packages/liveblocks-react/package.json index 470b21f0652..8446fc18709 100644 --- a/packages/liveblocks-react/package.json +++ b/packages/liveblocks-react/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react", - "version": "3.19.3", + "version": "3.19.4", "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", "author": "Liveblocks Inc.", @@ -56,7 +56,7 @@ "lint": "eslint src/", "lint:package": "publint --strict && attw --pack && bun scripts/check-exports.ts", "test": "vitest run", - "test:ci": "vitest run", + "test:ci": "vitest run --coverage", "test:types": "vitest run --config ./vitest.config.typecheck.ts", "test:watch": "vitest", "test:deps": "depcruise src --exclude __tests__", diff --git a/packages/liveblocks-redux/package.json b/packages/liveblocks-redux/package.json index 17fba93f580..dac1fc537c6 100644 --- a/packages/liveblocks-redux/package.json +++ b/packages/liveblocks-redux/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/redux", - "version": "3.19.3", + "version": "3.19.4", "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", "author": "Liveblocks Inc.", @@ -30,8 +30,8 @@ "format": "(eslint --fix src/ || true) && prettier --write src/", "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", - "test": "pnpm dlx liveblocks dev -p 1161 -c 'vitest run'", - "test:ci": "vitest run", + "test": "pnpm dlx liveblocks dev -P -c 'vitest run'", + "test:ci": "pnpm dlx liveblocks dev -P -c 'vitest run --coverage'", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/liveblocks-yjs/package.json b/packages/liveblocks-yjs/package.json index 56cb84ad84e..85d01c0ff58 100644 --- a/packages/liveblocks-yjs/package.json +++ b/packages/liveblocks-yjs/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/yjs", - "version": "3.19.3", + "version": "3.19.4", "description": "Integrate your existing or new Yjs documents with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -30,8 +30,8 @@ "format": "eslint --fix src/; prettier --write src/", "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", - "test": "pnpm dlx liveblocks dev -p 1163 -c 'vitest run'", - "test:ci": "vitest run", + "test": "pnpm dlx liveblocks dev -P -c 'vitest run'", + "test:ci": "pnpm dlx liveblocks dev -P -c 'vitest run --coverage'", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/liveblocks-zustand/package.json b/packages/liveblocks-zustand/package.json index 343ba065f34..839d868bbcd 100644 --- a/packages/liveblocks-zustand/package.json +++ b/packages/liveblocks-zustand/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/zustand", - "version": "3.19.3", + "version": "3.19.4", "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", "author": "Liveblocks Inc.", @@ -30,8 +30,8 @@ "format": "(eslint --fix src/ || true) && prettier --write src/", "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", - "test": "pnpm dlx liveblocks dev -p 1162 -c 'vitest run'", - "test:ci": "vitest run", + "test": "pnpm dlx liveblocks dev -P -c 'vitest run'", + "test:ci": "pnpm dlx liveblocks dev -P -c 'vitest run --coverage'", "test:types": "vitest run --config ./vitest.config.typecheck.ts", "test:watch": "vitest" },