Compare commits

..

3 Commits

Author SHA1 Message Date
agent_coder 515a1aaf1d Merge remote-tracking branch 'gitea/develop' into fix/535-list-seam-coalesce 2026-07-12 05:22:26 +03:00
agent_coder dd1fe90515 fix(prosemirror-markdown): guard three-way list coalesce against transitive style loss (#535)
Review follow-up on the list-seam coalescing:

1. The three-way merge only checked each PRE-EXISTING list against the
   inserted one. A default-typed inserted orderedList between two lists with
   explicit DIFFERENT numbering styles passed both pairwise checks through the
   null-typed middle, collapsing all three and silently losing the right
   list's style. Add a `listsMergeable(left, right)` guard to the three-way
   condition. On failure fall through to the single-seam path: a single
   inserted block can be absorbed by at most one neighbour, so prefer the LEFT
   seam (consistent with the three-way survivor choice) and leave the
   incompatible right list separate with its own style.

2. Lock the footnotesList exclusion with a test — inserting a footnotesList
   next to a footnotesList must leave TWO separate blocks (a refactor of the
   allow-list to endsWith("List") would otherwise silently merge them and
   corrupt footnotes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:06:54 +03:00
agent_coder 5bd5995ef0 fix(prosemirror-markdown): coalesce list seams on insert (#535)
insertNode with a markdown/JSON list now appends/prepends its items into
an adjacent same-type sibling list instead of creating a separate sibling
list that the serializer splits with `<!-- -->`.

Adds a single shared merge rule (isCoalescibleList/listsMergeable) used by
both the markdown path (insertNodesRelative) and the raw-node path
(insertNodeRelative). Coalescing is strictly local to the two seams of the
active insertion (each merged at most once — no greedy loop, no global
normalization). Survivor is chosen POSITIONALLY (the pre-existing neighbour
outside the inserted [i,j) range), so it keeps its block id and list-level
attrs (e.g. orderedList start); the inserted wrapper's re-minted id is
discarded. Handles the three-way case (list inserted between two same-type
lists → left survives) and refuses to coalesce an empty inserted list.

before/after now resolve via findAnchorChain and splice into the anchor's
immediate parent, so coalescing runs against the actual parent array
(incl. lists nested in callouts / table cells).

Serializer / LIST_MARKER_SEPARATOR untouched — two genuinely-separate
lists still emit `<!-- -->`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 02:43:53 +03:00
22 changed files with 624 additions and 1206 deletions
-1
View File
@@ -463,7 +463,6 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert.
## Conventions
-12
View File
@@ -142,18 +142,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
snapshots switched from a fixed interval to a trailing idle-flush with a
max-wait ceiling, and a boundary snapshot is pinned whenever the editing source
changes (e.g. a person's edits followed by the AI agent). (#370)
- **Open tabs pick up a new deploy on their own.** After the server is
redeployed while a tab is left open for hours, the tab now learns the new
build version over the existing WebSocket (announced per-connect, so a natural
reconnect delivers it) and shows a "A new version is available" banner with an
Update button. To avoid dropping a half-written comment or form, the tab is
not reloaded when you merely switch away from it; instead it auto-reloads at
the next safe point — the next in-app navigation (or immediately if you click
Update) — before it can hit a stale lazy-loaded chunk. At most one automatic
reload happens per 5-minute window, shared with the existing chunk-load
recovery, so a permanent version skew degrades to the banner rather than a
reload loop while a second deploy in the same tab still recovers. When the
build carries no version info the feature stays inert. (#481)
- **Place several images side by side in a row.** A new "Inline (side by
side)" alignment mode in the image bubble menu renders consecutive inline
@@ -1,5 +1,4 @@
{
"A new version is available": "A new version is available",
"Account": "Account",
"Active": "Active",
"Add": "Add",
@@ -1,5 +1,4 @@
{
"A new version is available": "Доступна новая версия",
"Account": "Аккаунт",
"Active": "Активный",
"Add": "Добавить",
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { isChunkLoadError } from "./chunk-load-error-boundary";
import { isChunkLoadError, shouldAutoReload } from "./chunk-load-error-boundary";
// The detector decides whether a caught render error is a stale-deploy chunk-404
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
@@ -35,3 +35,31 @@ describe("isChunkLoadError", () => {
expect(isChunkLoadError(err)).toBe(false);
});
});
// The window gate replaces the old one-shot flag: it must permit recovery across
// several deploys in one tab (each > window apart) while still stopping an infinite
// reload loop when a lazy chunk is permanently broken (a second failure < window).
describe("shouldAutoReload", () => {
const WINDOW = 5 * 60 * 1000;
const NOW = 1_000_000_000_000;
it("allows a reload when we have never auto-reloaded", () => {
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
});
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
});
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
});
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
});
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
});
});
@@ -1,11 +1,26 @@
import { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Button, Center, Stack, Text } from "@mantine/core";
import {
hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
} from "@/lib/reload-guard";
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload.
const RELOAD_AT_KEY = "chunk-reload-at";
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
// by a single reload, so anything inside the window is treated as a reload loop
// (permanently-broken chunk) and falls through to the manual UI. A window (rather
// than a one-shot flag) lets a SECOND deploy in the same tab's lifetime recover too.
const RELOAD_WINDOW_MS = 5 * 60 * 1000;
// Pure window decision, unit-tested in isolation: auto-reload only if we have never
// auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older than the
// window. Anything inside the window is suppressed to break an infinite reload loop.
export function shouldAutoReload(
now: number,
lastReloadAt: number | null,
windowMs: number,
): boolean {
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
return now - lastReloadAt > windowMs;
}
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
@@ -24,26 +39,24 @@ export function isChunkLoadError(error: unknown): boolean {
);
}
// Exported for tests: the reactive chunk-load reload decision, so the shared
// window budget (invariant: ≤1 auto-reload per window across this path AND the
// proactive version-coherence path) can be exercised against the real guard.
export function handleError(error: unknown) {
function handleError(error: unknown) {
if (!isChunkLoadError(error)) return;
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
// the new chunk manifest. Auto-reload at most once per window via the SHARED
// window-based reload guard (see @/lib/reload-guard — the same budget the
// proactive version-coherence path consumes, so a mismatch that arrives on
// both paths reloads at most once per window across BOTH). This recovers
// across multiple deploys in a single tab's lifetime, yet a permanently-broken
// lazy chunk (which would loop) is stopped after the first reload and falls
// through to the manual recovery UI below. If the shared budget is already
// spent this window, or the stamp write fails (storage unavailable), we return
// without reloading rather than risk a loop.
if (hasAutoReloaded()) return;
if (!markAutoReloaded()) return;
// Trace before the reload clears the console (same diagnostic breadcrumb the
// proactive version-coherence path writes, tagged with this path).
recordReloadBreadcrumb({ path: "chunk-boundary" });
// the new chunk manifest. Auto-reload at most once per RELOAD_WINDOW_MS: this
// recovers across multiple deploys in a single tab's lifetime, yet a
// permanently-broken lazy chunk (which would loop) is stopped after the first
// reload and falls through to the manual recovery UI below.
try {
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
const now = Date.now();
if (!shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS)) return;
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
} catch {
// sessionStorage unavailable (private mode / disabled): skip the automatic
// reload rather than risk an unguarded loop; the fallback UI still recovers.
return;
}
window.location.reload();
}
@@ -1,195 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, act, cleanup } from "@testing-library/react";
import { MemoryRouter, useNavigate } from "react-router-dom";
// Mocks for the dirty shell's side-effecting collaborators.
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
vi.mock("@/lib/reload-guard", () => ({
hasAutoReloaded: vi.fn(() => false),
markAutoReloaded: vi.fn(() => true),
recordReloadBreadcrumb: vi.fn(),
takeReloadBreadcrumb: vi.fn(() => null),
}));
import { notifications } from "@mantine/notifications";
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
import {
triggerGuardedReload,
useVersionReloadOnNavigation,
__resetGuardedReloadForTests,
} from "./guarded-reload";
const show = notifications.show as unknown as ReturnType<typeof vi.fn>;
const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType<
typeof vi.fn
>;
const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
typeof vi.fn
>;
let reload: ReturnType<typeof vi.fn>;
let visibility: DocumentVisibilityState;
// Test harness mounted inside a router: it installs the navigation hook and
// exposes `navigate` so a test can drive an in-app router navigation.
let doNavigate: (to: string) => void;
function Harness() {
useVersionReloadOnNavigation();
const navigate = useNavigate();
doNavigate = navigate;
return null;
}
function mountHarness() {
render(
<MemoryRouter initialEntries={["/start"]}>
<Harness />
</MemoryRouter>,
);
}
function navigateTo(path: string) {
act(() => {
doNavigate(path);
});
}
beforeEach(() => {
__resetGuardedReloadForTests();
vi.clearAllMocks();
mockHasAutoReloaded.mockReturnValue(false);
mockMarkAutoReloaded.mockReturnValue(true);
vi.stubGlobal("APP_VERSION", "test-A");
reload = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: { reload },
});
visibility = "visible";
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => visibility,
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("triggerGuardedReload (variant C)", () => {
it("noop when versions match: no banner, no reload", () => {
triggerGuardedReload("test-A");
expect(show).not.toHaveBeenCalled();
expect(reload).not.toHaveBeenCalled();
});
it("noop when the server version is empty (fail-safe)", () => {
triggerGuardedReload("");
triggerGuardedReload(undefined);
expect(show).not.toHaveBeenCalled();
expect(reload).not.toHaveBeenCalled();
});
it("real mismatch shows the banner but does NOT reload immediately", () => {
mountHarness();
triggerGuardedReload("test-B");
expect(show).toHaveBeenCalledTimes(1);
expect(show.mock.calls[0][0]).toMatchObject({
id: "app-version-reload",
autoClose: false,
withCloseButton: true,
});
expect(reload).not.toHaveBeenCalled();
});
it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
mountHarness();
triggerGuardedReload("test-B");
expect(reload).not.toHaveBeenCalled();
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
// A second navigation must NOT reload again (one-shot was consumed).
navigateTo("/again");
expect(reload).toHaveBeenCalledTimes(1);
});
it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
// Another app-version mismatch arrives (reconnect): must not re-arm.
triggerGuardedReload("test-C");
navigateTo("/again");
expect(reload).toHaveBeenCalledTimes(1);
});
it("does NOT reload merely from the tab going to the background", () => {
mountHarness();
triggerGuardedReload("test-B");
expect(reload).not.toHaveBeenCalled();
visibility = "hidden";
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(reload).not.toHaveBeenCalled();
});
it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => {
visibility = "hidden";
mountHarness();
triggerGuardedReload("test-B");
expect(reload).not.toHaveBeenCalled();
expect(show).toHaveBeenCalledTimes(1);
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
});
it("the banner's Update button reloads immediately", () => {
triggerGuardedReload("test-B");
const message = show.mock.calls[0][0].message as {
props: { onClick: () => void };
};
message.props.onClick();
expect(reload).toHaveBeenCalledTimes(1);
});
it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => {
mockHasAutoReloaded.mockReturnValue(true);
mountHarness();
triggerGuardedReload("test-B");
expect(show).toHaveBeenCalledTimes(1);
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
});
it("does NOT reload when the flag write fails; falls back to the banner", () => {
mockMarkAutoReloaded.mockReturnValue(false);
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
// performAutoReload falls back to showing the banner (initial + fallback).
expect(show).toHaveBeenCalled();
});
it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
triggerGuardedReload("test-B");
triggerGuardedReload("test-B");
triggerGuardedReload("test-C");
expect(show).toHaveBeenCalledTimes(1);
});
});
@@ -1,188 +0,0 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { Button } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import i18n from "@/i18n.ts";
import {
hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
takeReloadBreadcrumb,
} from "@/lib/reload-guard";
import { decideVersionAction } from "@/features/user/version-coherence";
// Dirty shell around the pure `decideVersionAction`: it reads globals
// (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the
// Mantine notification, and arms the router-navigation reload hook. Kept
// separate from the pure module so the decision stays unit-testable without a
// DOM.
// One fixed id so repeated app-version signals (e.g. every reconnect) update a
// single banner instead of stacking a new one each time.
const BANNER_ID = "app-version-reload";
// Module-level idempotency for the current tab-load: once a mismatch has been
// handled we don't re-arm the navigation reload or re-show the banner on
// subsequent app-version emits.
let handled = false;
// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to
// the background (that would silently drop a half-written comment/form). Instead
// we arm a one-shot reload for the NEXT in-app router navigation — a point where
// the user is already leaving the current page, so an in-app navigation would
// discard that unsaved component-state anyway and the reload adds no extra loss.
let pendingNavReload = false;
// Remembered from the last detected mismatch for the pre-reload breadcrumb and
// the (already-visible) banner.
let lastServerVersion = "";
let lastClientVersion = "";
// Read the build version baked into THIS bundle. The `typeof` guard avoids a
// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest,
// where Vite's `define` did not run) — an unknown client version makes the
// pure decision no-op (fail-safe).
function readClientVersion(): string {
return (typeof APP_VERSION !== "undefined" ? APP_VERSION : "").trim();
}
// Perform the actual reload — but only after the shared one-shot flag is
// persisted. If the write fails (storage unavailable) we must NOT reload
// (mirrors the reactive chunk-load boundary's `catch → return`), and fall back
// to the manual banner so the user can still recover.
function performAutoReload(): void {
if (!markAutoReloaded()) {
showReloadBanner();
return;
}
// Trace right before the reload (which clears the console): a persistent
// breadcrumb + a log line so the auto-reload is observable in a field report.
recordReloadBreadcrumb({
path: "proactive",
serverVersion: lastServerVersion,
clientVersion: lastClientVersion,
});
console.warn(
`[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`,
);
window.location.reload();
}
function showReloadBanner(): void {
notifications.show({
id: BANNER_ID,
title: i18n.t("A new version is available"),
message: (
<Button size="xs" mt="xs" onClick={() => performAutoReload()}>
{i18n.t("Update")}
</Button>
),
autoClose: false,
withCloseButton: true,
});
}
/**
* Handle a server `app-version` announcement: compare it to this bundle's
* version and, on a real mismatch, show the banner and arm a guarded reload for
* the next in-app navigation (variant C).
*
* - real mismatch (window budget available) banner + arm navigation reload.
* The banner's "Update" button reloads immediately (same shared window guard).
* The tab is NOT reloaded on visibility change.
* - auto-reload already used this window / storage error banner only (no arm),
* so there is at most one automatic reload per RELOAD_WINDOW_MS window (loop
* safety).
* - in sync / unknown version noop (fail-safe).
*/
export function triggerGuardedReload(
rawServerVersion: string | undefined | null,
): void {
const serverVersion = (rawServerVersion ?? "").trim();
const clientVersion = readClientVersion();
// A storage read error surfaces as autoReloadUsed=true → fail toward NOT
// reloading (banner only).
const autoReloadUsed = hasAutoReloaded();
const action = decideVersionAction({
serverVersion,
clientVersion,
autoReloadUsed,
});
if (action === "noop") return;
// Idempotent per tab-load: don't re-arm or re-stack the banner across repeated
// emits (reconnects) once we've already acted.
if (handled) return;
handled = true;
lastServerVersion = serverVersion;
lastClientVersion = clientVersion;
if (action === "banner") {
// Entered banner-only (permanent skew, node oscillation, or the window's
// auto-reload budget already spent). Log for diagnosability; show the banner.
console.warn(
`[version-coherence] server=${serverVersion} client=${clientVersion}: ` +
"auto-reload budget already spent this window — showing manual banner",
);
showReloadBanner();
return;
}
// action === "reload" (variant C): show the banner and defer the auto-reload
// to the next in-app navigation instead of reloading now / on visibility.
showReloadBanner();
pendingNavReload = true;
}
/**
* Consume the armed one-shot navigation reload, if any. Called by
* `useVersionReloadOnNavigation` on each in-app router navigation.
*/
export function consumeNavigationReload(): void {
if (!pendingNavReload) return;
pendingNavReload = false;
performAutoReload();
}
/**
* Hook (mounted inside the Router) that fires the armed one-shot reload on the
* NEXT in-app router navigation after a version mismatch. Skips the initial
* render so it only reacts to real navigations, not the first location.
*/
export function useVersionReloadOnNavigation(): void {
const location = useLocation();
const firstRender = useRef(true);
useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
return;
}
consumeNavigationReload();
}, [location.key]);
}
/**
* Surface (log once) the breadcrumb left by an auto-reload in the previous page
* load the reload cleared the console, so this makes a "tab reloaded itself"
* report diagnosable. Call once on app startup.
*/
export function surfacePreviousReloadBreadcrumb(): void {
const crumb = takeReloadBreadcrumb();
if (!crumb) return;
console.info(
`[version-coherence] previous auto-reload: path=${crumb.path} ` +
`client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` +
`at=${new Date(crumb.at).toISOString()}`,
);
}
// Test-only: reset module-level latches between cases.
export function __resetGuardedReloadForTests(): void {
handled = false;
pendingNavReload = false;
lastServerVersion = "";
lastClientVersion = "";
}
@@ -1,171 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, act, cleanup } from "@testing-library/react";
import { MemoryRouter, useNavigate } from "react-router-dom";
// Integration test for the SHARED, window-based auto-reload budget (invariant a):
// the reactive chunk-load boundary and the proactive version-coherence path both
// route through the REAL @/lib/reload-guard, so at most one automatic reload
// happens per RELOAD_WINDOW_MS across BOTH paths combined. Only the two paths'
// side-effecting collaborators are mocked — the reload guard is intentionally
// REAL so this exercises the actual shared sessionStorage budget.
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
import { handleError } from "@/components/chunk-load-error-boundary";
import {
triggerGuardedReload,
useVersionReloadOnNavigation,
__resetGuardedReloadForTests,
} from "./guarded-reload";
import { RELOAD_WINDOW_MS } from "@/lib/reload-guard";
const CHUNK_ERROR = { name: "ChunkLoadError", message: "boom" };
const T0 = 1_000_000_000_000;
let reload: ReturnType<typeof vi.fn>;
let nowMock: ReturnType<typeof vi.spyOn>;
// Harness mounted inside a router: installs the navigation hook and exposes
// `navigate` so a test can drive an in-app router navigation (the point where the
// proactive path fires its armed reload).
let doNavigate: (to: string) => void;
function Harness() {
useVersionReloadOnNavigation();
doNavigate = useNavigate();
return null;
}
function mountHarness() {
render(
<MemoryRouter initialEntries={["/start"]}>
<Harness />
</MemoryRouter>,
);
}
function navigateTo(path: string) {
act(() => {
doNavigate(path);
});
}
function setNow(t: number) {
nowMock.mockReturnValue(t);
}
beforeEach(() => {
sessionStorage.clear();
__resetGuardedReloadForTests();
vi.clearAllMocks();
nowMock = vi.spyOn(Date, "now").mockReturnValue(T0);
vi.stubGlobal("APP_VERSION", "test-A");
reload = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: { reload },
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
sessionStorage.clear();
});
describe("shared window-based reload budget (invariant a)", () => {
it("a chunk-load reload spends the budget: a version-coherence mismatch within the window shows the banner but does NOT reload", () => {
// Path 1 (reactive): a stale-chunk 404 auto-reloads once and stamps the window.
handleError(CHUNK_ERROR);
expect(reload).toHaveBeenCalledTimes(1);
reload.mockClear();
// Path 2 (proactive), 1 min later — still inside the window. The shared budget
// is spent, so the version mismatch degrades to the banner and never reloads.
setNow(T0 + 60_000);
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
});
it("a version-coherence reload spends the SAME budget: a chunk-load error within the window does NOT reload", () => {
// Path 2 (proactive) first: real mismatch → arm → fire on navigation.
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
reload.mockClear();
// Path 1 (reactive), 2 min later — inside the window. Budget already spent by
// the proactive path, so the stale-chunk error must NOT trigger a second reload.
setNow(T0 + 2 * 60_000);
handleError(CHUNK_ERROR);
expect(reload).not.toHaveBeenCalled();
});
it("recovers after the window: a reload strictly older than the window is allowed again (second deploy)", () => {
// First auto-reload (reactive) stamps the window.
handleError(CHUNK_ERROR);
expect(reload).toHaveBeenCalledTimes(1);
reload.mockClear();
// A second deploy arrives after the window has fully elapsed → the proactive
// path is allowed to reload again (window, not a permanent one-shot).
__resetGuardedReloadForTests();
setNow(T0 + RELOAD_WINDOW_MS + 1);
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
});
it("reactive path fails closed when storage READS but cannot WRITE (quota / Safari private): no unguarded reload", () => {
// getItem→null makes hasAutoReloaded() report the budget as available, so
// handleError passes the first guard and reaches `if (!markAutoReloaded())
// return;`. setItem throws → the stamp cannot stick, so markAutoReloaded()
// returns false and that guard MUST bail — otherwise the reactive path would
// reload on every stale-chunk error with no persisted budget (an unguarded
// loop). This is the asymmetric gap: the proactive path's equivalent is
// covered by guarded-reload.test.tsx "does NOT reload when the flag write
// fails".
vi.stubGlobal("sessionStorage", {
getItem: () => null,
setItem: () => {
throw new Error("quota exceeded");
},
removeItem: () => {},
clear: () => {},
});
try {
handleError(CHUNK_ERROR);
expect(reload).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
it("sessionStorage unavailable: neither path performs an unguarded reload", () => {
// The real guard fails toward NOT reloading when storage throws.
vi.stubGlobal("sessionStorage", {
getItem: () => {
throw new Error("storage disabled");
},
setItem: () => {
throw new Error("storage disabled");
},
removeItem: () => {},
clear: () => {},
});
try {
handleError(CHUNK_ERROR);
expect(reload).not.toHaveBeenCalled();
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
});
@@ -13,12 +13,6 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import { queryClient } from "@/main.tsx";
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
import {
triggerGuardedReload,
useVersionReloadOnNavigation,
surfacePreviousReloadBreadcrumb,
} from "@/features/user/guarded-reload.tsx";
import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts";
export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom);
@@ -28,16 +22,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
// fetch collab token on load
const { data: collab } = useCollabToken();
// version-coherence: fire the armed one-shot reload on the next in-app
// navigation (variant C — a safe point, not on tab backgrounding).
useVersionReloadOnNavigation();
// Surface any breadcrumb left by an auto-reload in the previous page load
// (the reload cleared the console) so a field report stays diagnosable.
useEffect(() => {
surfacePreviousReloadBreadcrumb();
}, []);
useEffect(() => {
if (isLoading || isError) {
return;
@@ -63,16 +47,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
handleConnect();
});
// Register the version-coherence listener SYNCHRONOUSLY, before the socket
// connects: the server emits `app-version` immediately in handleConnection,
// so a listener attached after connect would miss it on a fast localhost
// connect. On a version mismatch the client shows a banner and defers the
// auto-reload to the next in-app navigation (variant C — avoids reloading a
// backgrounded tab that may hold unsaved input) before it hits a stale chunk.
newSocket.on("app-version", (payload?: AppVersionSocketPayload) => {
triggerGuardedReload(payload?.version);
});
return () => {
console.log("ws disconnected");
newSocket.disconnect();
@@ -1,64 +0,0 @@
import { describe, it, expect } from "vitest";
import { decideVersionAction } from "./version-coherence";
describe("decideVersionAction", () => {
it("noop when the server version is empty (fail-safe)", () => {
expect(
decideVersionAction({
serverVersion: "",
clientVersion: "v1",
autoReloadUsed: false,
}),
).toBe("noop");
});
it("noop when the client version is empty (fail-safe)", () => {
expect(
decideVersionAction({
serverVersion: "v1",
clientVersion: "",
autoReloadUsed: false,
}),
).toBe("noop");
});
it("noop when versions are equal (in sync)", () => {
expect(
decideVersionAction({
serverVersion: "v1",
clientVersion: "v1",
autoReloadUsed: false,
}),
).toBe("noop");
});
it("reload on a real mismatch the first time this session", () => {
expect(
decideVersionAction({
serverVersion: "test-B",
clientVersion: "test-A",
autoReloadUsed: false,
}),
).toBe("reload");
});
it("banner on a mismatch once the session auto-reload is spent", () => {
expect(
decideVersionAction({
serverVersion: "test-B",
clientVersion: "test-A",
autoReloadUsed: true,
}),
).toBe("banner");
});
it("equal versions stay noop even if auto-reload was already used", () => {
expect(
decideVersionAction({
serverVersion: "v1",
clientVersion: "v1",
autoReloadUsed: true,
}),
).toBe("noop");
});
});
@@ -1,32 +0,0 @@
// Payload of the per-connect `app-version` socket.io event announced by the
// server (ws.gateway.ts) after a successful auth. A dedicated event — NOT a
// member of the room-scoped `WebSocketEvent` union (which is discriminated by
// `operation`), so it never touches use-query-subscription.
export type AppVersionSocketPayload = { version: string };
/**
* Pure decision for the version-coherence guard.
*
* All inputs are injected (no globals, no side effects) so it is unit-testable
* without a DOM or the build-time `APP_VERSION` global (undefined under vitest).
*
* - `autoReloadUsed` = an automatic reload has already happened within the
* current ~5-min window, so we must not auto-reload again (loop safety,
* shared window budget with the reactive chunk-load boundary).
*
* Returns:
* - "noop" do nothing (unknown version on either side, or already in sync).
* - "banner" show the manual "update available" banner only (no auto-reload).
* - "reload" real first-time mismatch: eligible for a guarded auto-reload.
*/
export function decideVersionAction(args: {
serverVersion: string;
clientVersion: string;
autoReloadUsed: boolean;
}): "reload" | "banner" | "noop" {
const { serverVersion, clientVersion, autoReloadUsed } = args;
if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act
if (serverVersion === clientVersion) return "noop"; // in sync
if (autoReloadUsed) return "banner"; // one auto-reload per RELOAD_WINDOW_MS window already spent
return "reload"; // real mismatch, window budget available
}
-145
View File
@@ -1,145 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
hasAutoReloaded,
markAutoReloaded,
shouldAutoReload,
recordReloadBreadcrumb,
takeReloadBreadcrumb,
RELOAD_WINDOW_MS,
} from "./reload-guard";
// The shared budget is a single sessionStorage timestamp keyed here; both the
// reactive chunk-load boundary and the proactive version-coherence path read and
// stamp it, so at most one auto-reload happens per RELOAD_WINDOW_MS across BOTH.
const RELOAD_AT_KEY = "chunk-reload-at";
const NOW = 1_000_000_000_000;
describe("reload-guard", () => {
beforeEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
afterEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
it("hasAutoReloaded is false before any reload, true within the window after mark", () => {
expect(hasAutoReloaded(NOW)).toBe(false);
expect(markAutoReloaded(NOW)).toBe(true);
// Same key both paths share; stores the reload timestamp, not a flag.
expect(sessionStorage.getItem(RELOAD_AT_KEY)).toBe(String(NOW));
// Inside the window → budget spent → true (fall through to manual UI).
expect(hasAutoReloaded(NOW)).toBe(true);
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS)).toBe(true);
});
it("hasAutoReloaded is false again once the window has elapsed (a later deploy recovers)", () => {
markAutoReloaded(NOW);
// Strictly older than the window → a new deploy's mismatch may reload again.
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS + 1)).toBe(false);
});
it("hasAutoReloaded returns true when reading storage throws (fail toward not reloading)", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => {
throw new Error("storage disabled");
},
setItem: () => {
throw new Error("storage disabled");
},
});
try {
expect(hasAutoReloaded()).toBe(true);
} finally {
vi.unstubAllGlobals();
}
});
it("hasAutoReloaded treats an unparseable stored timestamp as never-reloaded", () => {
sessionStorage.setItem(RELOAD_AT_KEY, "not-a-number");
expect(hasAutoReloaded(NOW)).toBe(false);
});
it("markAutoReloaded returns false when writing storage throws", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => null,
setItem: () => {
throw new Error("storage disabled");
},
});
try {
expect(markAutoReloaded()).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
it("records and then takes a breadcrumb once (cleared on read)", () => {
recordReloadBreadcrumb({
path: "proactive",
serverVersion: "test-B",
clientVersion: "test-A",
});
const crumb = takeReloadBreadcrumb();
expect(crumb).toMatchObject({
path: "proactive",
serverVersion: "test-B",
clientVersion: "test-A",
});
expect(typeof crumb?.at).toBe("number");
// Cleared on read → a second take returns null.
expect(takeReloadBreadcrumb()).toBeNull();
});
it("takeReloadBreadcrumb returns null when nothing was recorded", () => {
expect(takeReloadBreadcrumb()).toBeNull();
});
it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => null,
setItem: () => {
throw new Error("storage disabled");
},
removeItem: () => {},
});
try {
expect(() =>
recordReloadBreadcrumb({ path: "chunk-boundary" }),
).not.toThrow();
} finally {
vi.unstubAllGlobals();
}
});
});
// The pure window gate replaces the old one-shot flag: it must permit recovery
// across several deploys in one tab (each > window apart) while still stopping an
// infinite reload loop when a lazy chunk is permanently broken (a second failure
// < window). Moved here from the chunk-load boundary now that it is the shared
// guard both paths route through.
describe("shouldAutoReload", () => {
const WINDOW = RELOAD_WINDOW_MS;
it("allows a reload when we have never auto-reloaded", () => {
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
});
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
});
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
});
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
});
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
});
});
-121
View File
@@ -1,121 +0,0 @@
// Shared, window-based auto-reload budget.
//
// Both auto-reload paths — the reactive chunk-load-error-boundary (recovers
// AFTER a stale lazy chunk 404s) and the proactive version-coherence feature
// (reloads BEFORE the tab hits a stale chunk) — go through these functions so
// they share ONE window-scoped reload budget: at most a single automatic
// reload per RELOAD_WINDOW_MS across BOTH paths. A window (rather than a
// permanent one-shot flag) lets a SECOND deploy in the same tab's lifetime
// recover too, while a permanent skew, node oscillation, or a genuinely-missing
// chunk still degrades to a manual banner/UI after the first reload instead of
// looping. When sessionStorage is unavailable every mismatch degrades to the
// manual UI — no unguarded reload.
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload
// (shared by both paths).
const RELOAD_AT_KEY = "chunk-reload-at";
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
// by a single reload, so anything inside the window is treated as a reload loop
// (permanently-broken chunk / permanent skew) and falls through to the manual UI.
export const RELOAD_WINDOW_MS = 5 * 60 * 1000;
/**
* Pure window decision, unit-tested in isolation: auto-reload only if we have
* never auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older
* than the window. Anything inside the window is suppressed to break an infinite
* reload loop.
*/
export function shouldAutoReload(
now: number,
lastReloadAt: number | null,
windowMs: number,
): boolean {
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
return now - lastReloadAt > windowMs;
}
/**
* Has an automatic reload already happened within the current window (so the
* shared budget is spent right now)? Both paths check this before reloading; a
* `true` return means fall through to the manual banner/UI instead of reloading.
*
* A storage read error (private mode / disabled) is reported as `true` so the
* caller fails toward NOT reloading an unguarded loop is worse than a stale
* tab the user can reload manually. Note a window (not a permanent flag): once
* the window elapses a later deploy's mismatch is allowed to reload again.
*/
export function hasAutoReloaded(now: number = Date.now()): boolean {
try {
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
return !shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS);
} catch {
return true;
}
}
/**
* Stamp the shared window as consumed now record that an automatic reload is
* being performed within the current RELOAD_WINDOW_MS window.
*
* Returns whether the write succeeded. A `false` return (storage unavailable)
* means the caller MUST NOT reload otherwise the stamp would never stick and
* the reload could loop.
*/
export function markAutoReloaded(now: number = Date.now()): boolean {
try {
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
return true;
} catch {
return false;
}
}
// Diagnostic breadcrumb for an automatic reload. Written right before
// window.location.reload() (which clears the console) and read back on the next
// page load, so a "the tab reloaded itself / it's looping" field report is
// diagnosable: which path fired (proactive version-coherence vs the reactive
// chunk-load boundary) and which version pair triggered it. sessionStorage
// survives a same-tab reload, unlike the console.
const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb";
export type ReloadBreadcrumb = {
path: "proactive" | "chunk-boundary";
serverVersion?: string;
clientVersion?: string;
at: number;
};
/**
* Persist a best-effort breadcrumb just before an automatic reload. Failures
* (storage unavailable) are swallowed this is diagnostics only and must never
* block or alter the reload decision.
*/
export function recordReloadBreadcrumb(
entry: Omit<ReloadBreadcrumb, "at">,
): void {
try {
sessionStorage.setItem(
RELOAD_BREADCRUMB_KEY,
JSON.stringify({ ...entry, at: Date.now() }),
);
} catch {
// best-effort diagnostics only
}
}
/**
* Read and clear the breadcrumb left by an auto-reload in the previous page
* load. Cleared on read so it surfaces exactly once per reload.
*/
export function takeReloadBreadcrumb(): ReloadBreadcrumb | null {
try {
const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY);
if (!raw) return null;
sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY);
return JSON.parse(raw) as ReloadBreadcrumb;
} catch {
return null;
}
}
+2 -29
View File
@@ -1,8 +1,7 @@
import { defineConfig, loadEnv, type Plugin } from "vite";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import { compression } from "vite-plugin-compression2";
import * as path from "path";
import * as fs from "node:fs";
import { execSync } from "node:child_process";
const envPath = path.resolve(process.cwd(), "..", "..");
@@ -25,32 +24,7 @@ function resolveAppVersion(cwd: string): string {
}
}
// Emit <outDir>/version.json = { "version": appVersion } so the server can read
// the exact same build id the bundle was compiled with. The value is the SAME
// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in
// global are identical by construction — the single source of truth (no
// runtime-env second copy that could drift and cause a false version mismatch).
function versionJsonPlugin(version: string): Plugin {
let outDir = "dist";
return {
name: "emit-version-json",
apply: "build",
configResolved(config) {
outDir = config.build.outDir;
},
writeBundle() {
const root = path.resolve(process.cwd(), outDir);
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(
path.join(root, "version.json"),
JSON.stringify({ version }),
);
},
};
}
export default defineConfig(({ mode }) => {
const appVersion = resolveAppVersion(envPath);
const {
APP_URL,
FILE_UPLOAD_SIZE_LIMIT,
@@ -78,11 +52,10 @@ export default defineConfig(({ mode }) => {
POSTHOG_HOST,
POSTHOG_KEY,
},
APP_VERSION: JSON.stringify(appVersion),
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
},
plugins: [
react(),
versionJsonPlugin(appVersion),
// Emit .br and .gz next to every built asset so the server can serve the
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
compression({
@@ -1,52 +0,0 @@
import { join } from 'path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { readClientBuildVersion } from './client-version';
describe('readClientBuildVersion', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
const writeVersionJson = (content: string) =>
fs.writeFileSync(join(dir, 'version.json'), content);
it('returns the version from a valid version.json', () => {
writeVersionJson(JSON.stringify({ version: 'test-A' }));
expect(readClientBuildVersion(dir)).toBe('test-A');
});
it('trims surrounding whitespace in the version', () => {
writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' }));
expect(readClientBuildVersion(dir)).toBe('v1.2.3');
});
it('returns "" when version.json is missing', () => {
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" on malformed JSON', () => {
writeVersionJson('{ not json');
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" when the version field is absent', () => {
writeVersionJson(JSON.stringify({ notVersion: 'x' }));
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" when the version field is not a string', () => {
writeVersionJson(JSON.stringify({ version: 123 }));
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" when the path does not exist at all', () => {
expect(readClientBuildVersion(join(dir, 'nope'))).toBe('');
});
});
@@ -1,36 +0,0 @@
import { join } from 'path';
import * as fs from 'node:fs';
/**
* Resolve the absolute path to the built client bundle directory
* (`apps/client/dist`) shipped into the runtime image.
*
* The `../` depth is anchored on THIS module's compiled location
* (`dist/common/helpers`). `integrations/static` sits at the same depth under
* the compiled root, so both callers (StaticModule and readClientBuildVersion)
* MUST share this single helper rather than duplicating the depth a copy in a
* module at a different depth would silently resolve to the wrong directory.
*/
export function resolveClientDistPath(): string {
return join(__dirname, '..', '..', '..', '..', 'client/dist');
}
/**
* Read the build version the client bundle was compiled with, from
* `<clientDistPath>/version.json` (written by the Vite build the single
* source of truth shared by the baked-in `APP_VERSION` global and this file).
*
* Fail-safe: any error (missing file, unreadable, bad JSON, non-string
* version) yields `''`. The caller treats an empty version as "unknown" and
* the whole version-coherence feature stays silently inert existing deploys
* without the file keep working unchanged.
*/
export function readClientBuildVersion(clientDistPath: string): string {
try {
const raw = fs.readFileSync(join(clientDistPath, 'version.json'), 'utf8');
const version = (JSON.parse(raw) as { version?: unknown }).version;
return typeof version === 'string' ? version.trim() : '';
} catch {
return '';
}
}
-1
View File
@@ -3,4 +3,3 @@ export * from './nanoid.utils';
export * from './file.helper';
export * from './constants';
export * from './security-headers';
export * from './client-version';
@@ -4,7 +4,6 @@ import { join } from 'path';
import * as fs from 'node:fs';
import fastifyStatic from '@fastify/static';
import { EnvironmentService } from '../environment/environment.service';
import { resolveClientDistPath } from '../../common/helpers/client-version';
/**
* Resolve the response headers for a statically served client asset.
@@ -57,7 +56,14 @@ export class StaticModule implements OnModuleInit {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const app = httpAdapter.getInstance();
const clientDistPath = resolveClientDistPath();
const clientDistPath = join(
__dirname,
'..',
'..',
'..',
'..',
'client/dist',
);
const indexFilePath = join(clientDistPath, 'index.html');
+2 -37
View File
@@ -9,14 +9,10 @@ import {
import { Server, Socket } from 'socket.io';
import { TokenService } from '../core/auth/services/token.service';
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
import { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { OnModuleDestroy } from '@nestjs/common';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { WsService } from './ws.service';
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
import {
readClientBuildVersion,
resolveClientDistPath,
} from '../common/helpers/client-version';
import * as cookie from 'cookie';
@WebSocketGateway({
@@ -24,40 +20,17 @@ import * as cookie from 'cookie';
transports: ['websocket'],
})
export class WsGateway
implements
OnGatewayConnection,
OnGatewayInit,
OnModuleInit,
OnModuleDestroy
implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy
{
@WebSocketServer()
server: Server;
private readonly logger = new Logger(WsGateway.name);
// The build version of the client bundle shipped in this image, read once at
// startup from client/dist/version.json (single source of truth, same value
// baked into the client's APP_VERSION). Empty string => version.json missing
// or empty => the proactive version-coherence reload feature stays inert.
private appVersion = '';
constructor(
private tokenService: TokenService,
private spaceMemberRepo: SpaceMemberRepo,
private wsService: WsService,
) {}
onModuleInit(): void {
this.appVersion = readClientBuildVersion(resolveClientDistPath());
if (this.appVersion) {
this.logger.log(`app-version reload: ACTIVE (v=${this.appVersion})`);
} else {
this.logger.log(
'app-version reload: DISABLED (version.json missing/empty)',
);
}
}
afterInit(server: Server): void {
this.wsService.setServer(server);
}
@@ -82,14 +55,6 @@ export class WsGateway
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
client.join([userRoom, workspaceRoom, ...spaceRooms]);
// Announce this container's client build version to the freshly
// authenticated socket. On a redeploy the client reconnects to the new
// container and receives the new version here, letting it guard-reload
// before it hits a stale lazy chunk. Per-connect only (no broadcast):
// natural reconnect covers both single-container and cluster without a
// thundering-herd fleet reload.
client.emit('app-version', { version: this.appVersion });
} catch (err) {
client.emit('Unauthorized');
client.disconnect();
+157 -68
View File
@@ -633,6 +633,130 @@ function findAnchorChain(
return null;
}
// ===========================================================================
// List seam coalescing (#535)
//
// When a markdown/JSON insert places a list block directly next to an existing
// sibling list of the SAME node type, the two must become ONE list (items
// appended/prepended) rather than two adjacent lists — otherwise the serializer
// correctly emits a `<!-- -->` separator between them (that separator is right
// for two genuinely-separate lists; the bug is that the extra sibling was ever
// created). Coalescing is STRICTLY LOCAL to the two seams of the active
// insertion — never a global "collapse all adjacent lists" normalization, which
// would destroy intentionally-separate lists elsewhere.
// ===========================================================================
/**
* The three list container types we structurally coalesce at an insertion seam.
* Deliberately an explicit set, NOT a `type.endsWith("List")` test that also
* matches `footnotesList`, which must NEVER be structurally merged.
*/
function isCoalescibleList(n: any): boolean {
return (
isObject(n) &&
(n.type === "bulletList" ||
n.type === "orderedList" ||
n.type === "taskList")
);
}
/**
* True when two adjacent list nodes may have their ITEMS merged into one list.
* Requires identical node type and, for orderedList, a compatible numbering
* style: a missing/null `attrs.type` counts as the default, and the merge is
* blocked ONLY when both lists carry an explicit, differing `attrs.type`.
*/
function listsMergeable(a: any, b: any): boolean {
if (!isCoalescibleList(a) || !isCoalescibleList(b)) return false;
if (a.type !== b.type) return false;
if (!Array.isArray(a.content) || !Array.isArray(b.content)) return false;
if (a.type === "orderedList") {
const ta = a.attrs?.type ?? null;
const tb = b.attrs?.type ?? null;
if (ta != null && tb != null && ta !== tb) return false;
}
return true;
}
/**
* Coalesce list seams around a freshly inserted run occupying indices `[i, j)`
* in `parent`. At MOST the left seam (`parent[i-1]` `parent[i]`) and the right
* seam (`parent[j-1]` `parent[j]`) are merged, each AT MOST ONCE never a
* `while (neighbours same type) merge` loop, which would swallow a further-out
* intentionally-separate list. Mutates `parent` in place.
*
* Survivor choice is POSITIONAL, never by id: the neighbour OUTSIDE the `[i, j)`
* range is pre-existing (the survivor, keeping its block id and list-level
* attrs); the boundary block INSIDE the range is the freshly inserted list,
* whose items move into the survivor and whose wrapper is then deleted
* (appended when the survivor is on the left, prepended when on the right).
*
* Empty inserted list: if the inserted boundary list has zero items, its seam is
* NOT coalesced the block is left exactly as inserted.
*/
function coalesceSeams(parent: any[], i: number, j: number): void {
if (!Array.isArray(parent)) return;
const n = parent.length;
const left = parent[i - 1];
const boundaryLeft = parent[i];
const boundaryRight = parent[j - 1];
const right = parent[j];
// A seam fires only when the pre-existing neighbour and the inserted boundary
// list are mergeable AND the inserted boundary list is non-empty.
const singleBlock = i === j - 1;
const leftMergeable =
i - 1 >= 0 &&
listsMergeable(left, boundaryLeft) &&
boundaryLeft.content.length > 0;
let rightMergeable =
j < n &&
listsMergeable(boundaryRight, right) &&
boundaryRight.content.length > 0;
// Three-way collision: a SINGLE inserted list (singleBlock) landed exactly
// between two pre-existing lists. The LEFT pre-existing list wins: the
// inserted items then the right list's items fold into it, and both the
// inserted wrapper and the right pre-existing list are deleted (the right
// block id is NOT preserved — rare, documented).
//
// The `listsMergeable(left, right)` guard is REQUIRED: leftMergeable and
// rightMergeable only check each PRE-EXISTING list against the inserted one.
// A default-typed inserted orderedList is compatible with BOTH neighbours
// even when the neighbours carry explicit DIFFERENT numbering styles, so
// without this guard the two would collapse transitively through the middle
// and the right list's style would be silently lost. When it fails we fall
// through to the single-seam path below (never a transitive merge).
if (singleBlock && leftMergeable && rightMergeable && listsMergeable(left, right)) {
left.content.push(...boundaryLeft.content, ...right.content);
parent.splice(i, 2);
return;
}
// A single inserted block can be absorbed by at most ONE neighbour. When both
// seams are individually valid but the neighbours are mutually incompatible
// (the three-way guard above failed), prefer the LEFT seam — consistent with
// the three-way survivor choice — and drop the right so the incompatible
// right list stays separate with its own style.
if (singleBlock && leftMergeable && rightMergeable) {
rightMergeable = false;
}
// Otherwise the two seams are independent. Process the RIGHT seam FIRST so its
// deletion at the higher indices cannot shift the left seam's [i-1, i].
if (rightMergeable) {
// Survivor is the pre-existing right neighbour; PREPEND the inserted items.
right.content.unshift(...boundaryRight.content);
parent.splice(j - 1, 1);
}
if (leftMergeable) {
// Survivor is the pre-existing left neighbour; APPEND the inserted items.
left.content.push(...boundaryLeft.content);
parent.splice(i, 1);
}
}
/** Options controlling where `insertNodeRelative` places the new node. */
export interface InsertOptions {
position: "before" | "after" | "append";
@@ -685,7 +809,10 @@ export function insertNodeRelative(
}
if (isObject(out)) {
if (!Array.isArray(out.content)) out.content = [];
const at = out.content.length;
out.content.push(fresh);
// Coalesce the left seam with the prior tail block (#535).
coalesceSeams(out.content, at, out.content.length);
return { doc: out, inserted: true };
}
return { doc: out, inserted: false };
@@ -737,40 +864,19 @@ export function insertNodeRelative(
return { doc: out, inserted: true };
}
// Resolve by id anywhere in the tree: splice into the parent content array.
if (opts.anchorNodeId != null) {
let inserted = false;
const walkContent = (content: any[]): void => {
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (matchesId(child, opts.anchorNodeId as string)) {
content.splice(i + offset, 0, fresh);
inserted = true;
return;
}
if (isObject(child) && Array.isArray(child.content)) {
walkContent(child.content);
if (inserted) return;
}
}
};
if (isObject(out) && Array.isArray(out.content)) {
walkContent(out.content);
}
return { doc: out, inserted };
}
// Resolve by text: only top-level doc.content blocks are scanned. Exact
// match wins; a markdown-stripped fallback is tried only on a miss.
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
const i = findAnchorTextIndex(out.content, opts.anchorText);
if (i !== -1) {
out.content.splice(i + offset, 0, fresh);
return { doc: out, inserted: true };
}
}
return { doc: out, inserted: false };
// before/after (non-structural): resolve the anchor's ancestor chain and
// splice into the anchor's IMMEDIATE parent, so seam coalescing runs against
// the ACTUAL parent array (also correctly handling a list nested in a callout
// / table cell). The first-match / top-level-only semantics of anchorNodeId /
// anchorText are preserved by findAnchorChain (identical to the old walk).
const chain = findAnchorChain(out, opts);
if (chain == null || chain.length < 2) return { doc: out, inserted: false };
const parent = chain[chain.length - 2].node.content;
if (!Array.isArray(parent)) return { doc: out, inserted: false };
const at = chain[chain.length - 1].index + offset;
parent.splice(at, 0, fresh);
coalesceSeams(parent, at, at + 1);
return { doc: out, inserted: true };
}
/**
@@ -811,7 +917,11 @@ export function insertNodesRelative(
if (opts.position === "append") {
if (isObject(out)) {
if (!Array.isArray(out.content)) out.content = [];
const at = out.content.length;
out.content.push(...fresh);
// Coalesce the left seam with the prior tail block; the run's right side
// has no neighbour at the top level (#535).
coalesceSeams(out.content, at, out.content.length);
return { doc: out, inserted: true };
}
return { doc: out, inserted: false };
@@ -819,40 +929,19 @@ export function insertNodesRelative(
const offset = opts.position === "after" ? 1 : 0;
// Resolve by id anywhere in the tree: splice the whole array into the parent.
if (opts.anchorNodeId != null) {
let inserted = false;
const walkContent = (content: any[]): void => {
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (matchesId(child, opts.anchorNodeId as string)) {
content.splice(i + offset, 0, ...fresh);
inserted = true;
return;
}
if (isObject(child) && Array.isArray(child.content)) {
walkContent(child.content);
if (inserted) return;
}
}
};
if (isObject(out) && Array.isArray(out.content)) {
walkContent(out.content);
}
return { doc: out, inserted };
}
// Resolve by text: only top-level doc.content blocks are scanned. Exact match
// wins; a markdown-stripped fallback is tried only on a miss.
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
const i = findAnchorTextIndex(out.content, opts.anchorText);
if (i !== -1) {
out.content.splice(i + offset, 0, ...fresh);
return { doc: out, inserted: true };
}
}
return { doc: out, inserted: false };
// before/after: resolve the anchor's ancestor chain and splice the whole run
// into the anchor's IMMEDIATE parent, then coalesce ONLY the run's two
// boundary seams (inner blocks are untouched). Converting to findAnchorChain
// makes coalescing run against the ACTUAL parent array (incl. lists nested in
// callouts / table cells); first-match / top-level-only semantics preserved.
const chain = findAnchorChain(out, opts);
if (chain == null || chain.length < 2) return { doc: out, inserted: false };
const parent = chain[chain.length - 2].node.content;
if (!Array.isArray(parent)) return { doc: out, inserted: false };
const at = chain[chain.length - 1].index + offset;
parent.splice(at, 0, ...fresh);
coalesceSeams(parent, at, at + fresh.length);
return { doc: out, inserted: true };
}
// ===========================================================================
@@ -0,0 +1,390 @@
import { describe, expect, it } from "vitest";
import {
insertNodesRelative,
insertNodeRelative,
} from "../src/lib/node-ops.js";
import { markdownToProseMirrorSync } from "../src/lib/markdown-to-prosemirror.js";
import { convertProseMirrorToMarkdown } from "../src/lib/markdown-converter.js";
// #535: inserting a markdown/JSON list next to an existing same-type sibling
// list must APPEND/PREPEND its items into that list (one list), not leave two
// adjacent lists that the serializer separates with `<!-- -->`. Coalescing is
// strictly local to the two seams of the active insertion.
// ---- Minimal builders (structure is what these tests assert) ---------------
function item(label: string | number): any {
return {
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: String(label) }] },
],
};
}
function taskItem(label: string | number, checked = false): any {
return {
type: "taskItem",
attrs: { checked },
content: [
{ type: "paragraph", content: [{ type: "text", text: String(label) }] },
],
};
}
function bulletList(id: string, ...labels: (string | number)[]): any {
return { type: "bulletList", attrs: { id }, content: labels.map(item) };
}
function orderedList(
id: string,
start: number,
attrsType: string | null,
...labels: (string | number)[]
): any {
return {
type: "orderedList",
attrs: { id, start, type: attrsType },
content: labels.map(item),
};
}
function taskList(id: string, ...labels: (string | number)[]): any {
return { type: "taskList", attrs: { id }, content: labels.map((l) => taskItem(l)) };
}
/** Read a list block's item labels (the text of each item's first paragraph). */
function labels(list: any): string[] {
return (list.content ?? []).map(
(it: any) => it.content?.[0]?.content?.[0]?.text,
);
}
describe("#535 list seam coalescing — markdown path (insertNodesRelative)", () => {
it("crit1: append a bulletList onto an adjacent bulletList -> ONE list", () => {
const doc = { type: "doc", content: [bulletList("A", 1, 2, 3)] };
const { doc: out, inserted } = insertNodesRelative(
doc,
[bulletList("B", 4)],
{ position: "append" },
);
expect(inserted).toBe(true);
// Exactly one block, and it is the pre-existing survivor (id "A").
expect(out.content.length).toBe(1);
expect(out.content[0].type).toBe("bulletList");
expect(out.content[0].attrs.id).toBe("A");
expect(labels(out.content[0])).toEqual(["1", "2", "3", "4"]);
});
it("crit2: append an orderedList -> ONE list, original start kept, 1..4 continuous, no start:1 sibling", () => {
const doc = { type: "doc", content: [orderedList("A", 1, null, 1, 2, 3)] };
const { doc: out } = insertNodesRelative(
doc,
[orderedList("B", 1, null, 4)],
{ position: "append" },
);
expect(out.content.length).toBe(1);
expect(out.content[0].type).toBe("orderedList");
expect(out.content[0].attrs.start).toBe(1);
expect(labels(out.content[0])).toEqual(["1", "2", "3", "4"]);
});
it("crit3: after/before by anchorNodeId on a same-type list -> one merged list", () => {
const docA = { type: "doc", content: [bulletList("A", 1, 2)] };
const afterRes = insertNodesRelative(docA, [bulletList("B", 3)], {
position: "after",
anchorNodeId: "A",
});
expect(afterRes.doc.content.length).toBe(1);
expect(labels(afterRes.doc.content[0])).toEqual(["1", "2", "3"]);
const docB = { type: "doc", content: [bulletList("A", 2, 3)] };
const beforeRes = insertNodesRelative(docB, [bulletList("B", 1)], {
position: "before",
anchorNodeId: "A",
});
expect(beforeRes.doc.content.length).toBe(1);
// Survivor is the pre-existing "A"; inserted items PREPEND.
expect(beforeRes.doc.content[0].attrs.id).toBe("A");
expect(labels(beforeRes.doc.content[0])).toEqual(["1", "2", "3"]);
});
it("crit3: anchorText resolving to a list block also merges", () => {
const doc = { type: "doc", content: [bulletList("A", "alpha", "beta")] };
const { doc: out } = insertNodesRelative(doc, [bulletList("B", "gamma")], {
position: "after",
anchorText: "alpha",
});
expect(out.content.length).toBe(1);
expect(labels(out.content[0])).toEqual(["alpha", "beta", "gamma"]);
});
it("crit4: both seams (three-way) — inserting a list between two same-type lists -> ONE list in order", () => {
const doc = {
type: "doc",
content: [bulletList("A", 1, 2), bulletList("C", 4, 5)],
};
const { doc: out } = insertNodesRelative(doc, [bulletList("B", 3)], {
position: "after",
anchorNodeId: "A",
});
expect(out.content.length).toBe(1);
// LEFT pre-existing "A" survives; order A-items, inserted, C-items.
expect(out.content[0].attrs.id).toBe("A");
expect(labels(out.content[0])).toEqual(["1", "2", "3", "4", "5"]);
});
it("crit4: multi-block run coalesces BOTH boundary seams, inner block untouched", () => {
// parent = [preA, Bi, para, Bj, preB]; run [Bi, para, Bj] inserted after preA.
const doc = {
type: "doc",
content: [bulletList("A", 1), bulletList("E", 9)],
};
const run = [
bulletList("Bi", 2),
{ type: "paragraph", content: [{ type: "text", text: "mid" }] },
bulletList("Bj", 8),
];
const { doc: out } = insertNodesRelative(doc, run, {
position: "after",
anchorNodeId: "A",
});
// Expect: [A(1,2), para(mid), E(8,9)] — preA absorbed Bi, preE absorbed Bj.
expect(out.content.map((n: any) => n.type)).toEqual([
"bulletList",
"paragraph",
"bulletList",
]);
expect(labels(out.content[0])).toEqual(["1", "2"]);
expect(out.content[1].content[0].text).toBe("mid");
expect(labels(out.content[2])).toEqual(["8", "9"]);
expect(out.content[0].attrs.id).toBe("A");
expect(out.content[2].attrs.id).toBe("E");
});
it("crit4b: three-way with DIFFERING explicit ordered types does NOT transitively merge through a default-typed insertion", () => {
// [ol(type:"a")[1], ol(type:"i")[9]] + insert ol(type:null)[5] after A.
// The null-typed middle is pairwise-compatible with BOTH neighbours, but the
// neighbours are mutually incompatible ("a" vs "i"), so they must NOT
// collapse. The inserted list is absorbed by the LEFT survivor; the RIGHT
// list stays separate and keeps its roman numbering style.
const doc = {
type: "doc",
content: [orderedList("A", 1, "a", 1), orderedList("C", 9, "i", 9)],
};
const { doc: out } = insertNodeRelative(doc, orderedList("B", 5, null, 5), {
position: "after",
anchorNodeId: "A",
});
expect(out.content.length).toBe(2);
// Left survivor absorbs the inserted item.
expect(out.content[0].attrs.id).toBe("A");
expect(out.content[0].attrs.type).toBe("a");
expect(labels(out.content[0])).toEqual(["1", "5"]);
// Right list is untouched: its explicit style survives.
expect(out.content[1].attrs.id).toBe("C");
expect(out.content[1].attrs.type).toBe("i");
expect(labels(out.content[1])).toEqual(["9"]);
});
it("crit6: before for orderedList{start:5} keeps start:5 (positional survivor = pre-existing)", () => {
const doc = { type: "doc", content: [orderedList("A", 5, null, "a", "b")] };
const { doc: out } = insertNodesRelative(
doc,
[orderedList("B", 1, null, "c")],
{ position: "before", anchorNodeId: "A" },
);
expect(out.content.length).toBe(1);
expect(out.content[0].attrs.id).toBe("A");
expect(out.content[0].attrs.start).toBe(5);
expect(labels(out.content[0])).toEqual(["c", "a", "b"]);
});
it("crit7: different list types do NOT coalesce", () => {
// bulletList next to orderedList
const d1 = { type: "doc", content: [bulletList("A", 1)] };
const r1 = insertNodesRelative(d1, [orderedList("B", 1, null, 2)], {
position: "append",
});
expect(r1.doc.content.length).toBe(2);
expect(r1.doc.content.map((n: any) => n.type)).toEqual([
"bulletList",
"orderedList",
]);
// bulletList next to taskList
const d2 = { type: "doc", content: [bulletList("A", 1)] };
const r2 = insertNodesRelative(d2, [taskList("B", 2)], {
position: "append",
});
expect(r2.doc.content.length).toBe(2);
expect(r2.doc.content.map((n: any) => n.type)).toEqual([
"bulletList",
"taskList",
]);
});
it("crit7b: orderedLists with explicit DIFFERING attrs.type do NOT coalesce", () => {
const doc = { type: "doc", content: [orderedList("A", 1, "a", 1)] };
const { doc: out } = insertNodesRelative(
doc,
[orderedList("B", 1, "i", 2)],
{ position: "append" },
);
expect(out.content.length).toBe(2);
});
it("footnotesList is NEVER structurally coalesced (allow-list excludes it, guarding against endsWith(\"List\"))", () => {
const fn = (id: string): any => ({
type: "footnotesList",
attrs: { id },
content: [
{ type: "footnoteDefinition", attrs: { id: id + "d" }, content: [] },
],
});
const doc = { type: "doc", content: [fn("A")] };
const { doc: out } = insertNodeRelative(doc, fn("B"), {
position: "append",
});
// Two footnotesLists must stay two separate blocks — merging them would
// corrupt footnotes.
expect(out.content.length).toBe(2);
expect(out.content.map((n: any) => n.type)).toEqual([
"footnotesList",
"footnotesList",
]);
});
it("taskList next to taskList coalesces, item checked attrs move with items", () => {
const doc = {
type: "doc",
content: [
{
type: "taskList",
attrs: { id: "A" },
content: [taskItem("x", true)],
},
],
};
const { doc: out } = insertNodesRelative(
doc,
[{ type: "taskList", attrs: { id: "B" }, content: [taskItem("y", false)] }],
{ position: "append" },
);
expect(out.content.length).toBe(1);
expect(out.content[0].content.map((it: any) => it.attrs.checked)).toEqual([
true,
false,
]);
});
});
describe("#535 list seam coalescing — node path (insertNodeRelative)", () => {
it("append a single bulletList node onto an adjacent bulletList -> ONE list", () => {
const doc = { type: "doc", content: [bulletList("A", 1, 2)] };
const { doc: out } = insertNodeRelative(doc, bulletList("B", 3), {
position: "append",
});
expect(out.content.length).toBe(1);
expect(labels(out.content[0])).toEqual(["1", "2", "3"]);
});
it("crit8: EMPTY inserted list is NOT coalesced (stays as inserted)", () => {
const doc = { type: "doc", content: [bulletList("A", 1, 2)] };
const empty = { type: "bulletList", attrs: { id: "B" }, content: [] };
const { doc: out, inserted } = insertNodeRelative(doc, empty, {
position: "append",
});
expect(inserted).toBe(true);
expect(out.content.length).toBe(2);
expect(out.content[1].attrs.id).toBe("B");
expect(out.content[1].content.length).toBe(0);
});
it("crit5b: nested parent — merging a list next to a list INSIDE a callout, top level untouched", () => {
const doc = {
type: "doc",
content: [
{
type: "callout",
attrs: { id: "co" },
content: [bulletList("A", 1, 2)],
},
],
};
const { doc: out } = insertNodeRelative(doc, bulletList("B", 3), {
position: "after",
anchorNodeId: "A",
});
// Top level unchanged: still one callout.
expect(out.content.length).toBe(1);
expect(out.content[0].type).toBe("callout");
// The callout's content array holds ONE merged bulletList.
expect(out.content[0].content.length).toBe(1);
expect(out.content[0].content[0].type).toBe("bulletList");
expect(labels(out.content[0].content[0])).toEqual(["1", "2", "3"]);
});
});
describe("#535 locality (regression guard) + round-trip", () => {
it("crit5a: appending next to ONE of two intentionally-separate lists merges only that seam; the further-out separate list survives and still emits <!-- -->", () => {
// Reimport a doc with two deliberately-separate bullet lists: [(x,y),(z)].
const doc = markdownToProseMirrorSync("- x\n- y\n\n<!-- -->\n\n- z");
expect(doc.content.filter((n: any) => n.type === "bulletList").length).toBe(
2,
);
// Append a list. It lands at the end, adjacent to the SECOND list (z) and
// must merge ONLY into it — the FIRST list (x,y) is the intentionally-
// separate one two hops away and must NOT be swallowed (a greedy `while`
// merge would reach and collapse it, which this guards against).
const { doc: out, inserted } = insertNodesRelative(
doc,
[bulletList("NEW", "q")],
{ position: "append" },
);
expect(inserted).toBe(true);
// Still TWO lists: (x,y) stayed separate; (z) absorbed the appended item.
expect(out.content.filter((n: any) => n.type === "bulletList").length).toBe(
2,
);
expect(labels(out.content[0])).toEqual(["x", "y"]);
expect(labels(out.content[1])).toEqual(["z", "q"]);
// Serialize: the separator between the two separate lists remains.
const md = convertProseMirrorToMarkdown(out);
expect(md).toContain("<!-- -->");
});
it("idempotency: re-running the transform on a fresh doc yields the same result", () => {
const doc = { type: "doc", content: [bulletList("A", 1, 2, 3)] };
const r1 = insertNodesRelative(doc, [bulletList("B", 4)], {
position: "append",
});
const r2 = insertNodesRelative(doc, [bulletList("B", 4)], {
position: "append",
});
expect(r2.doc).toEqual(r1.doc);
// Input never mutated.
expect(doc.content.length).toBe(1);
expect(labels(doc.content[0])).toEqual(["1", "2", "3"]);
});
it("#351 P1/P2: a merged list serializes to one list and is byte-fixpoint on the 2nd pass", () => {
// Build a merged bulletList via a canonical import so the round-trip is real.
const base = markdownToProseMirrorSync("- one\n- two");
const add = markdownToProseMirrorSync("- three");
const { doc: merged } = insertNodesRelative(base, add.content, {
position: "append",
});
expect(merged.content.length).toBe(1);
const md1 = convertProseMirrorToMarkdown(merged);
// A single list — no separator inside.
expect(md1).not.toContain("<!-- -->");
expect(md1).toBe("- one\n- two\n- three");
// Reimport -> still ONE bulletList; re-serialize -> byte-identical (fixpoint).
const reimported = markdownToProseMirrorSync(md1);
expect(reimported.content.filter((n: any) => n.type === "bulletList").length).toBe(1);
const md2 = convertProseMirrorToMarkdown(reimported);
expect(md2).toBe(md1);
});
});