mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
fix(web): a body the server marks as behind its live document says so wherever it is rendered — share pages, the read-only pane, the diff, and a dot on marked rows in lists, backlinks and the palette (BUG-3050 U3) (#1577)
The server marks a body that is behind its live collaborative document with `content_state: applied_pending_flush` (BUG-3000). No browser render read it: share pages dropped it in field copies, and every other surface showed the stale body unmarked. - One helper (lib/items/staleBody.ts) and two components carry the ruled treatments: a one-line notice "This may not include the latest edits." above a body that is the view's subject (share page incl. the password-unlocked copy, the public collection's inline expansion, the item pane's read-only viewer, the timeline diff's current side), and a muted dot with that text as tooltip and label on MARKED rows only (conventions list, playbooks list, backlinks, command palette). Unmarked rows are unchanged. - A body kept locally over a fresh server row keeps its OWN marker (adoptServerItem and four save/refresh paths), and an Item built with a blank body from an index row drops the row's marker, matching the server's rule that the marker is emitted only where a real body is. - Guard tables re-read and re-stamped for the ItemDetail edits; a new guard pins the kept/blanked-body population. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
This commit is contained in:
@@ -80,7 +80,8 @@ import (
|
||||
// quick-action prompts, the conventions and playbook editors, and
|
||||
// ItemDetail's editor-seed and timeline-diff strings. An earlier version of
|
||||
// this comment described that surface as a pure rendering choice, which was
|
||||
// inaccurate.
|
||||
// inaccurate. BUG-3050 closed it in three units: the writes (U1), the agent
|
||||
// push (U2) and the renders (U3, `web/src/lib/items/staleBody.ts`).
|
||||
func TestBodyDerivedTextSitesAreAllRuledOn(t *testing.T) {
|
||||
// The helpers whose output is item-body text under another name. Adding one
|
||||
// here is how this guard learns about a new shape.
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { browserLogin } from './lib/collab-helpers';
|
||||
import { authJson } from './lib/attachment-viewer';
|
||||
import type { Page, APIRequestContext, Route } from '@playwright/test';
|
||||
import type { SuiteFixture } from './fixtures';
|
||||
|
||||
/**
|
||||
* BUG-3050 U3: every page that renders a body the server marked as behind the
|
||||
* live document (`content_state: applied_pending_flush`, BUG-3000) says so.
|
||||
* The ruled treatments: a one-line notice above a body that is the view's
|
||||
* subject (share pages, the read-only item pane, the diff's current side), and
|
||||
* a muted dot on MARKED rows only where the body is a snippet among many
|
||||
* (conventions, playbooks, the command palette; backlinks is a unit leg).
|
||||
*
|
||||
* These legs pin the BINDING from each page's API response to its render, so
|
||||
* the marker is added to the real response by interception, on one row only:
|
||||
* a real marker lasts only until a tab flushes, which no leg can schedule. The
|
||||
* components themselves are pinned in src/lib/items/staleBodyRenders.svelte.test.ts.
|
||||
*/
|
||||
|
||||
const MARK = 'applied_pending_flush';
|
||||
const NOTICE = 'This may not include the latest edits.';
|
||||
|
||||
async function create(fixture: SuiteFixture, request: APIRequestContext, coll: string, title: string, body: string, fields: Record<string, unknown> = {}) {
|
||||
const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections/${coll}/items`, {
|
||||
headers: authJson(fixture),
|
||||
data: { title, content: body, fields: JSON.stringify(fields) },
|
||||
});
|
||||
if (!resp.ok()) throw new Error(`create failed (${resp.status()}): ${await resp.text()}`);
|
||||
return (await resp.json()) as { slug: string; ref: string; id: string };
|
||||
}
|
||||
|
||||
async function share(fixture: SuiteFixture, request: APIRequestContext, path: string, data: Record<string, unknown> = {}) {
|
||||
const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/${path}/share-links`, {
|
||||
headers: authJson(fixture),
|
||||
data,
|
||||
});
|
||||
if (!resp.ok()) throw new Error(`share failed (${resp.status()}): ${await resp.text()}`);
|
||||
return ((await resp.json()) as { token: string }).token;
|
||||
}
|
||||
|
||||
/** Fulfil `route` with the real response, rewritten by `edit`. */
|
||||
async function rewrite(route: Route, edit: (json: any) => void) {
|
||||
const resp = await route.fetch();
|
||||
const json = await resp.json();
|
||||
edit(json);
|
||||
await route.fulfill({ response: resp, json });
|
||||
}
|
||||
|
||||
/** Mark the rows of a JSON array (or `{items}`/`{results}` wrapper) whose id is in `ids`. */
|
||||
function markRows(json: any, ids: string[]) {
|
||||
const rows: any[] = Array.isArray(json) ? json : (json.items ?? json.results ?? []);
|
||||
let marked = 0;
|
||||
for (const r of rows) {
|
||||
const row = r.item ?? r;
|
||||
if (ids.includes(row.id)) { row.content_state = MARK; marked++; }
|
||||
}
|
||||
return marked;
|
||||
}
|
||||
|
||||
/** Mark the item GET for `id`, whatever URL the pane reads it by (ref, slug, query). */
|
||||
async function markItemReads(page: Page, fixture: SuiteFixture, id: string) {
|
||||
await page.route(`**/api/v1/workspaces/${fixture.workspaceSlug}/items/**`, async (r) => {
|
||||
if (r.request().method() !== 'GET') return r.continue();
|
||||
const resp = await r.fetch();
|
||||
const text = await resp.text();
|
||||
let json: any;
|
||||
try { json = JSON.parse(text); } catch { return r.fulfill({ response: resp, body: text }); }
|
||||
if (json && !Array.isArray(json) && json.id === id) json.content_state = MARK;
|
||||
await r.fulfill({ response: resp, json });
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('stale body renders (BUG-3050 U3)', () => {
|
||||
test.setTimeout(90_000);
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'one project is enough for a render binding');
|
||||
});
|
||||
|
||||
test('share page: a marked item shows the notice above its body, an unmarked one does not (C1)', async ({ browser, fixture, request }) => {
|
||||
const stamp = Date.now();
|
||||
const item = await create(fixture, request, 'docs', `Shared ${stamp}`, 'Shared body text.');
|
||||
const token = await share(fixture, request, `items/${item.slug}`);
|
||||
const anon = await browser.newContext();
|
||||
try {
|
||||
const p = await anon.newPage();
|
||||
await p.goto(`/s/${token}`);
|
||||
await expect(p.locator('.item-content')).toContainText('Shared body text.');
|
||||
await expect(p.getByText(NOTICE)).toHaveCount(0);
|
||||
|
||||
await p.route(`**/api/v1/s/${token}`, (r) => rewrite(r, (j) => { j.item.content_state = MARK; }));
|
||||
await p.reload();
|
||||
await expect(p.locator('.item-content')).toContainText('Shared body text.');
|
||||
await expect(p.getByText(NOTICE)).toBeVisible();
|
||||
} finally {
|
||||
await anon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('share page: the password-unlocked copy carries the marker too (C1)', async ({ browser, fixture, request }) => {
|
||||
const stamp = Date.now();
|
||||
const item = await create(fixture, request, 'docs', `Locked ${stamp}`, 'Locked body text.');
|
||||
const token = await share(fixture, request, `items/${item.slug}`, { password: `pw-${stamp}` });
|
||||
const anon = await browser.newContext();
|
||||
try {
|
||||
const p = await anon.newPage();
|
||||
await p.route(`**/api/v1/s/${token}`, (r) => rewrite(r, (j) => { if (j.item) j.item.content_state = MARK; }));
|
||||
await p.goto(`/s/${token}`);
|
||||
await p.getByPlaceholder(/password/i).fill(`pw-${stamp}`);
|
||||
await p.getByRole('button', { name: /View content/ }).click();
|
||||
await expect(p.locator('.item-content')).toContainText('Locked body text.');
|
||||
await expect(p.getByText(NOTICE)).toBeVisible();
|
||||
} finally {
|
||||
await anon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('shared collection: the inline expansion of a marked item shows the notice, its neighbour\'s does not (C2/C3)', async ({ browser, fixture, request }) => {
|
||||
const stamp = Date.now();
|
||||
const marked = await create(fixture, request, 'docs', `Coll marked ${stamp}`, `Marked expansion body ${stamp}.`);
|
||||
const plain = await create(fixture, request, 'docs', `Coll plain ${stamp}`, `Plain expansion body ${stamp}.`);
|
||||
const token = await share(fixture, request, 'collections/docs');
|
||||
const anon = await browser.newContext();
|
||||
try {
|
||||
const p = await anon.newPage();
|
||||
// Public share items carry no id (an allow-list payload), so mark by title.
|
||||
await p.route(`**/api/v1/s/${token}`, (r) => rewrite(r, (j) => {
|
||||
for (const it of j.items ?? []) if (it.title === `Coll marked ${stamp}`) it.content_state = MARK;
|
||||
}));
|
||||
await p.goto(`/s/${token}`);
|
||||
for (const [it, body, noticeCount] of [[plain, `Plain expansion body ${stamp}.`, 0], [marked, `Marked expansion body ${stamp}.`, 1]] as const) {
|
||||
await p.getByText(`Coll ${it === marked ? 'marked' : 'plain'} ${stamp}`).first().click();
|
||||
const region = p.getByRole('region', { name: new RegExp(`Coll ${it === marked ? 'marked' : 'plain'} ${stamp} details`) });
|
||||
await expect(region).toContainText(body);
|
||||
await expect(region.getByText(NOTICE)).toHaveCount(noticeCount);
|
||||
}
|
||||
} finally {
|
||||
await anon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('item pane: a read-only viewer sees the notice above a marked body (C4)', async ({ page, fixture, request }) => {
|
||||
const stamp = Date.now();
|
||||
const item = await create(fixture, request, 'docs', `Viewer ${stamp}`, `Viewer body ${stamp}.`);
|
||||
await browserLogin(page);
|
||||
// A viewer's membership, on the one call canEdit derives from.
|
||||
await page.route(`**/api/v1/workspaces/${fixture.workspaceSlug}/me`, (r) => rewrite(r, (j) => { j.role = 'viewer'; j.item_grants = []; j.collection_grants = []; }));
|
||||
await markItemReads(page, fixture, item.id);
|
||||
await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/docs?item=${item.ref}`);
|
||||
await expect(page.getByText(`Viewer body ${stamp}.`)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText(NOTICE)).toBeVisible();
|
||||
});
|
||||
|
||||
test('item pane: a read-only viewer on an unmarked item sees no notice (C4 control)', async ({ page, fixture, request }) => {
|
||||
const stamp = Date.now();
|
||||
const item = await create(fixture, request, 'docs', `Viewer ctl ${stamp}`, `Viewer ctl body ${stamp}.`);
|
||||
await browserLogin(page);
|
||||
await page.route(`**/api/v1/workspaces/${fixture.workspaceSlug}/me`, (r) => rewrite(r, (j) => { j.role = 'viewer'; j.item_grants = []; j.collection_grants = []; }));
|
||||
await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/docs?item=${item.ref}`);
|
||||
await expect(page.getByText(`Viewer ctl body ${stamp}.`)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText(NOTICE)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('versions tab: the diff against a marked current body shows the notice (C5)', async ({ page, fixture, request }) => {
|
||||
const stamp = Date.now();
|
||||
const item = await create(fixture, request, 'docs', `Diff ${stamp}`, `First body ${stamp}.`);
|
||||
const upd = await request.patch(`/api/v1/workspaces/${fixture.workspaceSlug}/items/${item.slug}`, {
|
||||
headers: authJson(fixture),
|
||||
data: { content: `Second body ${stamp}.` },
|
||||
});
|
||||
expect(upd.ok(), await upd.text()).toBeTruthy();
|
||||
await browserLogin(page);
|
||||
await markItemReads(page, fixture, item.id);
|
||||
await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/docs?item=${item.ref}`);
|
||||
await page.getByRole('tab', { name: 'Versions' }).click();
|
||||
const card = page.locator('.card-header').first();
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.click();
|
||||
await expect(page.locator('.diff-container').first()).toBeVisible();
|
||||
await expect(page.locator('.diff-container').first().getByText(NOTICE)).toBeVisible();
|
||||
});
|
||||
|
||||
async function dotsIn(page: Page, rowSelector: string, title: string) {
|
||||
return page.locator(rowSelector).filter({ hasText: title }).locator('[data-testid="stale-body-dot"]');
|
||||
}
|
||||
|
||||
test('conventions list: a dot on the marked row only (C6)', async ({ page, fixture, request }) => {
|
||||
const stamp = Date.now();
|
||||
const marked = await create(fixture, request, 'conventions', `Conv marked ${stamp}`, 'Rule body.', { status: 'active', trigger: 'always' });
|
||||
await create(fixture, request, 'conventions', `Conv plain ${stamp}`, 'Rule body.', { status: 'active', trigger: 'always' });
|
||||
await browserLogin(page);
|
||||
await page.route(`**/api/v1/workspaces/${fixture.workspaceSlug}/collections/conventions/items*`, (r) => rewrite(r, (j) => markRows(j, [marked.id])));
|
||||
await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/conventions`);
|
||||
await expect(page.getByText(`Conv plain ${stamp}`)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(await dotsIn(page, '.convention-row', `Conv marked ${stamp}`)).toHaveCount(1);
|
||||
await expect(await dotsIn(page, '.convention-row', `Conv plain ${stamp}`)).toHaveCount(0);
|
||||
await expect((await dotsIn(page, '.convention-row', `Conv marked ${stamp}`)).first()).toHaveAttribute('title', NOTICE);
|
||||
});
|
||||
|
||||
test('playbooks list: a dot on the marked card only (C7)', async ({ page, fixture, request }) => {
|
||||
const stamp = Date.now();
|
||||
const marked = await create(fixture, request, 'playbooks', `PB marked ${stamp}`, '1. Step', { status: 'draft' });
|
||||
await create(fixture, request, 'playbooks', `PB plain ${stamp}`, '1. Step', { status: 'draft' });
|
||||
await browserLogin(page);
|
||||
await page.route(`**/api/v1/workspaces/${fixture.workspaceSlug}/collections/playbooks/items*`, (r) => rewrite(r, (j) => markRows(j, [marked.id])));
|
||||
await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/playbooks`);
|
||||
await expect(page.getByText(`PB plain ${stamp}`)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(await dotsIn(page, '.card', `PB marked ${stamp}`)).toHaveCount(1);
|
||||
await expect(await dotsIn(page, '.card', `PB plain ${stamp}`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('command palette: a content match from a marked item carries the dot, its neighbour does not (C9)', async ({ page, fixture, request }) => {
|
||||
const stamp = Date.now();
|
||||
const word = `zqpal${stamp.toString(36)}`;
|
||||
const marked = await create(fixture, request, 'docs', `Pal marked ${stamp}`, `Body mentions ${word} here.`);
|
||||
await create(fixture, request, 'docs', `Pal plain ${stamp}`, `Body mentions ${word} too.`);
|
||||
await browserLogin(page);
|
||||
await page.route('**/api/v1/search?*', (r) => rewrite(r, (j) => markRows(j, [marked.id])));
|
||||
await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/docs`);
|
||||
const input = page.getByPlaceholder('Search items, collections, docs...');
|
||||
// The shortcut is bound once the app shell has loaded; press until the palette opens.
|
||||
await expect(async () => {
|
||||
if (!(await input.isVisible())) await page.keyboard.press('Control+k');
|
||||
await expect(input).toBeVisible({ timeout: 1_000 });
|
||||
}).toPass({ timeout: 20_000 });
|
||||
await input.fill(word);
|
||||
const rows = page.locator('button.result');
|
||||
await expect(rows.filter({ hasText: `Pal plain ${stamp}` })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(rows.filter({ hasText: `Pal marked ${stamp}` }).locator('[data-testid="stale-body-dot"]')).toHaveCount(1);
|
||||
await expect(rows.filter({ hasText: `Pal plain ${stamp}` }).locator('[data-testid="stale-body-dot"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import type { Backlink } from '$lib/types';
|
||||
import { relativeTime } from '$lib/utils/markdown';
|
||||
import StaleBodyDot from '$lib/components/common/StaleBodyDot.svelte';
|
||||
import { isBodyStale } from '$lib/items/staleBody';
|
||||
|
||||
interface Props {
|
||||
wsSlug: string;
|
||||
@@ -220,7 +222,7 @@
|
||||
</span>
|
||||
</div>
|
||||
{#if bl.snippet}
|
||||
<div class="snippet">{bl.snippet}</div>
|
||||
<div class="snippet">{#if isBodyStale(bl)}<StaleBodyDot />{/if}{bl.snippet}</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
// A muted dot on a row whose body snippet the server marked as behind
|
||||
// (BUG-3050 U3). Rendered on MARKED rows only; callers gate it.
|
||||
import { STALE_BODY_NOTICE } from '$lib/items/staleBody';
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="stale-body-dot"
|
||||
role="img"
|
||||
aria-label={STALE_BODY_NOTICE}
|
||||
title={STALE_BODY_NOTICE}
|
||||
data-testid="stale-body-dot"
|
||||
></span>
|
||||
|
||||
<style>
|
||||
.stale-body-dot {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: 0 var(--space-1);
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
// The one-line notice above a body the server marked as behind (BUG-3050 U3).
|
||||
import { STALE_BODY_NOTICE } from '$lib/items/staleBody';
|
||||
</script>
|
||||
|
||||
<p class="stale-body-notice" role="note" data-testid="stale-body-notice">{STALE_BODY_NOTICE}</p>
|
||||
|
||||
<style>
|
||||
.stale-body-notice {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: 0.85em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -23,6 +23,8 @@
|
||||
import { sseService } from '$lib/services/sse.svelte';
|
||||
import { visibility } from '$lib/services/visibility.svelte';
|
||||
import Editor from '$lib/components/editor/Editor.svelte';
|
||||
import StaleBodyNotice from '$lib/components/common/StaleBodyNotice.svelte';
|
||||
import { isBodyStale } from '$lib/items/staleBody';
|
||||
import EditorBubbleMenu from '$lib/components/editor/EditorBubbleMenu.svelte';
|
||||
import EditorLinkPopover from '$lib/components/editor/EditorLinkPopover.svelte';
|
||||
import RawMarkdownEditor from '$lib/components/editor/RawMarkdownEditor.svelte';
|
||||
@@ -3748,11 +3750,12 @@
|
||||
|
||||
// Realtime-refresh convenience: applies the content-adoption rule (under
|
||||
// collab the editor reads Y.Doc so adopt server content verbatim; otherwise
|
||||
// preserve the live local content) and the tag overlay.
|
||||
// preserve the live local content) and the tag overlay. The kept body keeps
|
||||
// ITS `content_state` (BUG-3050 U3): the new row's marker describes the new
|
||||
// row's body, and a render reading it would label a body it was not about.
|
||||
function adoptServerItem(updated: Item): Item {
|
||||
return withInflightTags(
|
||||
collabProvider ? updated : { ...updated, content: item?.content ?? updated.content }
|
||||
);
|
||||
if (collabProvider || item?.content == null) return withInflightTags(updated);
|
||||
return withInflightTags({ ...updated, content: item.content, content_state: item.content_state });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4538,7 +4541,7 @@
|
||||
// Newer pending edit; keep local content, adopt
|
||||
// server-side metadata only. The next debounce
|
||||
// cycle will land the queued edit.
|
||||
item = withInflightTags({ ...updated, content: item.content });
|
||||
item = withInflightTags({ ...updated, content: item.content, content_state: item.content_state });
|
||||
}
|
||||
}).catch((e) => {
|
||||
if (!item || item.id !== reqItemId || genAtSave !== loadGeneration) return;
|
||||
@@ -4660,7 +4663,7 @@
|
||||
// Newer edit pending — keep our local
|
||||
// content but adopt server-side metadata
|
||||
// (timestamps, version, modified_by).
|
||||
item = withInflightTags({ ...updated, content: item.content });
|
||||
item = withInflightTags({ ...updated, content: item.content, content_state: item.content_state });
|
||||
}
|
||||
} catch (e) {
|
||||
// Don't surface A's save failure over B (a superseded
|
||||
@@ -5377,7 +5380,7 @@
|
||||
// Refresh item to update parent info
|
||||
const refreshed = await api.items.get(targetWs, targetSlug);
|
||||
if (switchedAway(targetItem, gen) || !item) return;
|
||||
item = withInflightTags({ ...refreshed, content: item.content });
|
||||
item = withInflightTags({ ...refreshed, content: item.content, content_state: item.content_state });
|
||||
toastStore.show('Relationship removed', 'success');
|
||||
} catch (e: any) {
|
||||
if (switchedAway(targetItem, gen)) return;
|
||||
@@ -5428,7 +5431,7 @@
|
||||
// Refresh item to update parent info
|
||||
const refreshed = await api.items.get(targetWs, targetSlug);
|
||||
if (switchedAway(sourceItem, gen) || !item) return;
|
||||
item = withInflightTags({ ...refreshed, content: item.content });
|
||||
item = withInflightTags({ ...refreshed, content: item.content, content_state: item.content_state });
|
||||
toastStore.show('Relationship added', 'success');
|
||||
} catch (e: any) {
|
||||
if (switchedAway(sourceItem, gen)) return;
|
||||
@@ -6763,6 +6766,7 @@
|
||||
read-only y-binding is deferred to TASK-1266.
|
||||
-->
|
||||
{#if !canEdit}
|
||||
{#if isBodyStale(item)}<StaleBodyNotice />{/if}
|
||||
{#key `${item.id}:false:${identityKey}`}
|
||||
<Editor
|
||||
content={editorContent}
|
||||
@@ -6922,6 +6926,7 @@
|
||||
{username}
|
||||
{itemSlug}
|
||||
currentContent={item.content ?? ''}
|
||||
currentContentStale={isBodyStale(item)}
|
||||
items={localIndex.getAll(wsSlug)}
|
||||
onRestore={(updated) => { if (handedDown !== identityKey) return; handleVersionRestore(updated); }}
|
||||
flushBeforeRestore={flushCollabBeforeRestore}
|
||||
@@ -7163,6 +7168,7 @@
|
||||
{username}
|
||||
{itemSlug}
|
||||
currentContent={item.content ?? ''}
|
||||
currentContentStale={isBodyStale(item)}
|
||||
items={localIndex.getAll(wsSlug)}
|
||||
changeContext={timelineChangeContext}
|
||||
hostToken={attachmentHostToken}
|
||||
|
||||
@@ -180,6 +180,8 @@ const POPULATION: string[] = [
|
||||
'lib/components/common/MenuItem.svelte',
|
||||
'lib/components/common/ContentSkeleton.svelte',
|
||||
'lib/components/common/ContentError.svelte',
|
||||
// BUG-3050 U3: static notice text, no script beyond an import; no await, no send.
|
||||
'lib/components/common/StaleBodyNotice.svelte',
|
||||
'lib/components/collections/EditCollectionModal.svelte',
|
||||
'lib/components/ShareDialog.svelte',
|
||||
'lib/components/items/CopyItemDialog.svelte',
|
||||
|
||||
@@ -112,7 +112,7 @@ const ASYNC_FUNCTIONS: Record<string, Row> = {
|
||||
jumpToSection: { reviewed: '17407aeb431b', why: 'switches this instance\'s tab and scrolls to an anchor', may: ['document.getElementById', 'document.getElementById(anchorId).scrollIntoView'] },
|
||||
ensureGraphComp: { reviewed: 'c1565cfb8a18', why: 'lazy-loads a component module into this instance', may: ['ItemGraphComp', 'graphLoadError'] },
|
||||
handleCopyRef: { reviewed: 'a5e0a0394fcc', why: 'switchedAway before the copied flag' },
|
||||
loadData: { reviewed: '8eb393047b3e', why: 'IS the load: myGen against loadGeneration after every await; the BUG-3198 re-read of a changed item runs after the install, keyed on the installed item id, and fences itself on itemGen' },
|
||||
loadData: { reviewed: '7450124bf4bf', why: 'IS the load: myGen against loadGeneration after every await; the BUG-3198 re-read of a changed item runs after the install, keyed on the installed item id, and fences itself on itemGen' },
|
||||
startEditTitle: { reviewed: '17f04352d420', why: 'focuses and sizes the input it opened synchronously', may: ['el', 'titleInputEl.focus', 'titleInputEl.setSelectionRange'] },
|
||||
// Re-reviewed for BUG-2836: saveTitle's own lines are unchanged (the diff
|
||||
// touches only a $derived beside titleDraft and the textarea's aria
|
||||
@@ -161,7 +161,7 @@ const ASYNC_FUNCTIONS: Record<string, Row> = {
|
||||
updateAssignedUser: { reviewed: '5e4128e45ae7', why: 'gen against loadGeneration on both arms' + SETTLE_NOTE, may: ['saves.settle'] },
|
||||
updateAgentRole: { reviewed: '9b0a6af92cc2', why: 'gen against loadGeneration on both arms' + SETTLE_NOTE, may: ['saves.settle'] },
|
||||
flushRawIfPending: {
|
||||
reviewed: '425804645744',
|
||||
reviewed: 'b25da2794031',
|
||||
why: 'genAtFlush against loadGeneration after each PATCH; the re-entrancy waiter returns state; the finally clears this drain\'s own in-flight flag' + SETTLE_NOTE,
|
||||
may: ['saves.settle', 'rawFlushInFlight'],
|
||||
bareAwaits: ['await new Promise((r) => setTimeout(r, 50));'],
|
||||
@@ -184,8 +184,8 @@ const ASYNC_FUNCTIONS: Record<string, Row> = {
|
||||
handleCopied: { reviewed: 'f35fb099838d', why: 'switchedAway before adopting the refreshed item' },
|
||||
handleDelete: { reviewed: 'b66adcf84794', why: 'switchedAway on both arms' },
|
||||
handleRestore: { reviewed: 'c3a1732554a4', why: 'switchedAway on every arm' },
|
||||
handleDeleteLink: { reviewed: 'e51f23ad3790', why: 'switchedAway after each await' },
|
||||
handleCreateLink: { reviewed: '473b62faa93a', why: 'switchedAway after each await' },
|
||||
handleDeleteLink: { reviewed: 'd147285bb264', why: 'switchedAway after each await' },
|
||||
handleCreateLink: { reviewed: 'ef19e8ce0bc8', why: 'switchedAway after each await' },
|
||||
handleMove: { reviewed: 'da5513ba767f', why: 'stillOnSource() on every arm, including inside navIfStillCurrent' },
|
||||
};
|
||||
|
||||
@@ -396,7 +396,7 @@ const CONTINUATIONS: SignedRow[] = [
|
||||
may: ['renameOverride'],
|
||||
},
|
||||
{ call: /^setTimeout\($/, body: /copied = false/, why: 'copy-flag reset: switchedAway', reviewed: '8a8d4754017f' },
|
||||
{ call: /api\.items\.get\(wsSlug, itemSlug\)\.catch\($/, body: /./, why: 'loadData item fetch: sets a flag local to that load and re-throws', reviewed: 'f2267a80cece' },
|
||||
{ call: /api\.items\.get\(wsSlug, itemSlug\)\.catch\($/, body: /./, why: 'loadData item fetch: sets a flag local to that load and re-throws', reviewed: 'f46985737ace' },
|
||||
{
|
||||
call: /^setTimeout\($/,
|
||||
body: /staleConnecting = true/,
|
||||
@@ -412,7 +412,7 @@ const CONTINUATIONS: SignedRow[] = [
|
||||
body: /teardownFlushed/,
|
||||
in: 'onBeforeUnload',
|
||||
code: '() => { teardownFlushed = false; }',
|
||||
why: 're-arms the BUG-3005 teardown latch, itself identity-checked', reviewed: 'e598de5a2771',
|
||||
why: 're-arms the BUG-3005 teardown latch, itself identity-checked', reviewed: '05b21a3dd559',
|
||||
may: ['teardownFlushed'],
|
||||
},
|
||||
{ call: /^queueMicrotask\($/, body: /./, why: 'collab lazy seed: refuses a retired or re-identified context first', reviewed: '197a4a45d219' },
|
||||
@@ -429,32 +429,32 @@ const CONTINUATIONS: SignedRow[] = [
|
||||
call: /^setTimeout\($/,
|
||||
body: /content: toSave, client_write: nextClientWrite\(\) \}\)\.then/,
|
||||
in: 'handleContentUpdate',
|
||||
why: 'content debounce: loadData clears this timer before its first await, so the callback never runs across a load', reviewed: 'd05baa756ebd',
|
||||
why: 'content debounce: loadData clears this timer before its first await, so the callback never runs across a load', reviewed: '6858c043b334',
|
||||
startSafe: true,
|
||||
pin: (src, unit) => clearsBeforeFirstAwait(src, 'loadData', 'contentDebounceTimer') ?? assignedTo(src, unit, 'contentDebounceTimer'),
|
||||
},
|
||||
{ call: /\{ content: toSave, client_write: nextClientWrite\(\) \}\)\.then\($/, body: /^\(\) =>/, why: 'content save: switchedAway', reviewed: '160532c3b115' },
|
||||
{ call: /showSaved\(saveTok\); \}\)\.catch\($/, body: /./, why: 'content save failure: switchedAway', reviewed: '73e9f73724f5' },
|
||||
{ call: /\{ content: toSave, client_write: nextClientWrite\(\) \}\)\.then\($/, body: /^\(\) =>/, why: 'content save: switchedAway', reviewed: '5afbf6c10e2d' },
|
||||
{ call: /showSaved\(saveTok\); \}\)\.catch\($/, body: /./, why: 'content save failure: switchedAway', reviewed: '0cb1078817c7' },
|
||||
{
|
||||
call: /\.finally\($/,
|
||||
body: /^\(\) => saves\.settle\(saveTok\)$/,
|
||||
in: 'setTimeout(…)',
|
||||
code: '() => saves.settle(saveTok)',
|
||||
why: SETTLE_WHY,
|
||||
reviewed: '4fbcd658ebba',
|
||||
reviewed: '08fccaaec1ae',
|
||||
may: ['saves.settle'],
|
||||
},
|
||||
{ call: /\{ keepalive: true \}\) \.then\($/, body: /./, why: 'raw keepalive save: genAtSave against loadGeneration', reviewed: '758b235c3705' },
|
||||
{ call: /localDirty = false; \} \}\) \.catch\($/, body: /^\(\) => \{\}$/, why: 'raw keepalive failure: empty', reviewed: '66b6e4855355' },
|
||||
{ call: /reqItemId, \{ content: toSave, client_write: nextClientWrite\(\) \}\)\.then\($/, body: /./, why: 'raw foreground save: genAtSave against loadGeneration', reviewed: '109114fbb1b8' },
|
||||
{ call: /content: item\.content \}\); \} \}\)\.catch\($/, body: /./, why: 'raw foreground failure: genAtSave against loadGeneration', reviewed: 'a47d5c0c6f20' },
|
||||
{ call: /\{ keepalive: true \}\) \.then\($/, body: /./, why: 'raw keepalive save: genAtSave against loadGeneration', reviewed: 'b95a7c2a3e88' },
|
||||
{ call: /localDirty = false; \} \}\) \.catch\($/, body: /^\(\) => \{\}$/, why: 'raw keepalive failure: empty', reviewed: '2ad797bd41d1' },
|
||||
{ call: /reqItemId, \{ content: toSave, client_write: nextClientWrite\(\) \}\)\.then\($/, body: /./, why: 'raw foreground save: genAtSave against loadGeneration', reviewed: '05f885ec2567' },
|
||||
{ call: /content: item\.content, content_state: item\.content_state \}\); \} \}\)\.catch\($/, body: /./, why: 'raw foreground failure: genAtSave against loadGeneration', reviewed: '67af6367e790' },
|
||||
{
|
||||
call: /\.finally\($/,
|
||||
body: /^\(\) => saves\.settle\(saveTok\)$/,
|
||||
in: '{save}',
|
||||
code: '() => saves.settle(saveTok)',
|
||||
why: SETTLE_WHY,
|
||||
reviewed: '0ef0106d13cc',
|
||||
reviewed: 'd16dc96fd13d',
|
||||
may: ['saves.settle'],
|
||||
},
|
||||
];
|
||||
@@ -468,7 +468,9 @@ const CONTINUATIONS: SignedRow[] = [
|
||||
const HELPERS: Record<string, string> = {
|
||||
primeCanonicalSeed: '9d90b6bf0245',
|
||||
adoptCollection: 'ab38368cd8fb',
|
||||
adoptServerItem: '198d4de5b450',
|
||||
// BUG-3050 U3: the kept local body keeps ITS content_state. Still synchronous,
|
||||
// reads only collabProvider and item, and commits only through withInflightTags.
|
||||
adoptServerItem: 'e11e0282e197',
|
||||
applyProgress: 'b28014625819',
|
||||
autoResizeTitle: '7b1e2fcd0526',
|
||||
captureIdentity: '01f30996ab05',
|
||||
@@ -477,7 +479,7 @@ const HELPERS: Record<string, string> = {
|
||||
identityHeld: '1c5505d51f73',
|
||||
navigateToCollectionRoot: '64dce75693d3',
|
||||
refreshPrintMeta: '2312cc481ca5',
|
||||
runTeardownFlush: '8251de42632d',
|
||||
runTeardownFlush: '6c7e2a216f4f',
|
||||
// BUG-3124 unit B: the cursor-advance settle. Synchronous; arms the flusher's
|
||||
// single timer only for the current, identity-held context.
|
||||
showSaved: '456dd972dcf5',
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
import { getFieldValue, itemUrlId, formatItemRef } from '$lib/types';
|
||||
import { relativeTime } from '$lib/utils/markdown';
|
||||
import { fieldMatches } from '$lib/fields/fieldShape';
|
||||
import StaleBodyDot from '$lib/components/common/StaleBodyDot.svelte';
|
||||
import { isBodyStale } from '$lib/items/staleBody';
|
||||
|
||||
const RECENT_SEARCHES_KEY = 'pad-recent-searches';
|
||||
const MAX_RECENT = 10;
|
||||
@@ -198,7 +200,7 @@
|
||||
// The CommandPalette only reads title/fields/ref/etc., so an
|
||||
// empty content body is fine — and matches the same widening
|
||||
// pattern the collection page uses at TASK-1357.
|
||||
const item: Item = { ...(row as ItemIndexRow), content: '' } as Item;
|
||||
const item: Item = { ...(row as ItemIndexRow), content: '', content_state: undefined } as Item;
|
||||
out.push({
|
||||
item,
|
||||
snippet: '',
|
||||
@@ -1042,6 +1044,7 @@
|
||||
</div>
|
||||
{#if r.snippet}
|
||||
<div class="result-snippet">
|
||||
{#if isBodyStale(r.item)}<StaleBodyDot />{/if}
|
||||
{stripHtml(r.snippet)}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1089,7 +1092,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if r.snippet}
|
||||
<div class="result-snippet">{stripHtml(r.snippet)}</div>
|
||||
<div class="result-snippet">{#if isBodyStale(r.item)}<StaleBodyDot />{/if}{stripHtml(r.snippet)}</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -1166,7 +1169,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if r.snippet}
|
||||
<div class="result-snippet">{stripHtml(r.snippet)}</div>
|
||||
<div class="result-snippet">{#if isBodyStale(r.item)}<StaleBodyDot />{/if}{stripHtml(r.snippet)}</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
PUBLIC_RELATION_TITLE,
|
||||
} from './shareView';
|
||||
import { isRelationType } from '$lib/items/relationFieldTypes';
|
||||
import StaleBodyNotice from '$lib/components/common/StaleBodyNotice.svelte';
|
||||
|
||||
interface Props {
|
||||
item: PublicItem;
|
||||
@@ -97,6 +98,7 @@
|
||||
{/if}
|
||||
|
||||
{#if html}
|
||||
{#if item.contentStale}<StaleBodyNotice />{/if}
|
||||
<!-- `html` is pre-sanitized by the route's marked()+DOMPurify pipeline.
|
||||
No new XSS surface — same sanitized source as the single-item view. -->
|
||||
<div class="expansion-content">
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { FieldDef } from '$lib/types';
|
||||
import { isRelationType, relationValuesOf } from '$lib/items/relationFieldTypes';
|
||||
import { UNPARENTED_FILTER_FIELD } from '$lib/collections/unparentedFilter';
|
||||
import { fieldMatches, safeString, safeText } from '$lib/fields/fieldShape';
|
||||
import { isBodyStale } from '$lib/items/staleBody';
|
||||
|
||||
// Re-exported so existing/future imports of `UNPARENTED_FILTER_FIELD` from
|
||||
// this module keep working — the canonical definition lives in
|
||||
@@ -58,6 +59,9 @@ export interface PublicItem {
|
||||
ref: string;
|
||||
fields: Record<string, unknown>;
|
||||
content: string;
|
||||
/** True when the server marked `content` as behind the live document
|
||||
* (`content_state`, BUG-3000); renderers say so (BUG-3050 U3). */
|
||||
contentStale: boolean;
|
||||
}
|
||||
|
||||
// ── Defensive parsing ───────────────────────────────────────────────────────
|
||||
@@ -160,7 +164,8 @@ export function parsePublicItem(raw: unknown, index = 0): PublicItem {
|
||||
title: typeof obj.title === 'string' && obj.title ? obj.title : 'Untitled',
|
||||
ref,
|
||||
fields: coerceObject(obj.fields),
|
||||
content: typeof obj.content === 'string' ? obj.content : ''
|
||||
content: typeof obj.content === 'string' ? obj.content : '',
|
||||
contentStale: isBodyStale(obj as { content_state?: string })
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
username?: string;
|
||||
itemSlug: string;
|
||||
currentContent: string;
|
||||
/** The server marked `currentContent` as behind the live document
|
||||
* (BUG-3000 `content_state`); the diff says so (BUG-3050 U3). */
|
||||
currentContentStale?: boolean;
|
||||
items?: Item[];
|
||||
onRestore?: (item: Item) => void;
|
||||
/**
|
||||
@@ -128,7 +131,7 @@
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
let { wsSlug, username = '', itemSlug, currentContent, items = [], onRestore, itemId, collectionId, frozen = false, restoreFrozen = false, flushBeforeRestore, visibleKinds, hostToken = '', parentArchived = false, feed = $bindable(), title = 'Timeline', emptyLabel = 'No timeline entries yet.' }: Props = $props();
|
||||
let { wsSlug, username = '', itemSlug, currentContent, currentContentStale = false, items = [], onRestore, itemId, collectionId, frozen = false, restoreFrozen = false, flushBeforeRestore, visibleKinds, hostToken = '', parentArchived = false, feed = $bindable(), title = 'Timeline', emptyLabel = 'No timeline entries yet.' }: Props = $props();
|
||||
|
||||
// Resolve canEditItem reactively; falls to false if itemId/collectionId
|
||||
// aren't supplied (e.g. an older caller). Folds in the master-freeze gate
|
||||
@@ -1430,6 +1433,7 @@
|
||||
onRemoveReaction={handleRemoveReaction}
|
||||
{itemSlug}
|
||||
{currentContent}
|
||||
{currentContentStale}
|
||||
{onRestore}
|
||||
{flushBeforeRestore}
|
||||
{restoreFrozen}
|
||||
|
||||
@@ -78,6 +78,9 @@
|
||||
// Version cards.
|
||||
itemSlug?: string;
|
||||
currentContent?: string;
|
||||
/** The server marked `currentContent` as behind the live document
|
||||
* (BUG-3000 `content_state`); the diff says so (BUG-3050 U3). */
|
||||
currentContentStale?: boolean;
|
||||
onRestore?: (item: Item) => void;
|
||||
flushBeforeRestore?: () => Promise<void>;
|
||||
restoreFrozen?: boolean;
|
||||
@@ -105,6 +108,7 @@
|
||||
onRemoveReaction = () => {},
|
||||
itemSlug = '',
|
||||
currentContent = '',
|
||||
currentContentStale = false,
|
||||
onRestore,
|
||||
flushBeforeRestore,
|
||||
restoreFrozen = false
|
||||
@@ -153,6 +157,7 @@
|
||||
{wsSlug}
|
||||
{itemSlug}
|
||||
{currentContent}
|
||||
{currentContentStale}
|
||||
{onRestore}
|
||||
{flushBeforeRestore}
|
||||
frozen={frozen || restoreFrozen}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { api, isContentPendingFlushError } from '$lib/api/client';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import DiffView from '$lib/components/versions/DiffView.svelte';
|
||||
import StaleBodyNotice from '$lib/components/common/StaleBodyNotice.svelte';
|
||||
import Chip from '$lib/components/common/Chip.svelte';
|
||||
import { relativeTime } from '$lib/utils/markdown';
|
||||
|
||||
@@ -11,6 +12,9 @@
|
||||
wsSlug: string;
|
||||
itemSlug: string;
|
||||
currentContent: string;
|
||||
/** The server marked `currentContent` as behind the live document
|
||||
* (BUG-3000 `content_state`); the diff says so (BUG-3050 U3). */
|
||||
currentContentStale?: boolean;
|
||||
onRestore?: (item: Item) => void;
|
||||
/**
|
||||
* PLAN-2154 Phase 2 / D2 / R12 (TASK-2172): master-freeze. The restore
|
||||
@@ -36,7 +40,7 @@
|
||||
flushBeforeRestore?: () => Promise<void>;
|
||||
}
|
||||
|
||||
let { version, wsSlug, itemSlug, currentContent, onRestore, frozen = false, flushBeforeRestore }: Props = $props();
|
||||
let { version, wsSlug, itemSlug, currentContent, currentContentStale = false, onRestore, frozen = false, flushBeforeRestore }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let confirming = $state(false);
|
||||
@@ -226,6 +230,7 @@
|
||||
{:else if resolveError}
|
||||
<p class="diff-status">Couldn't load this version's content.</p>
|
||||
{:else if displayContent !== null}
|
||||
{#if currentContentStale}<StaleBodyNotice />{/if}
|
||||
<DiffView oldContent={displayContent} newContent={currentContent} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// How a browser render says a body may be behind (BUG-3050 U3).
|
||||
//
|
||||
// A row whose `content_state` is `applied_pending_flush` serves a body the
|
||||
// server knows is BEHIND the live collaborative document (BUG-3000): an open
|
||||
// tab holds edits the row does not have yet. Every surface that renders such
|
||||
// a body says so, in one of two treatments ruled on the trail:
|
||||
//
|
||||
// - a one-line notice above the body, where the body is the page's subject
|
||||
// (share pages, the read-only item pane, the diff's current side);
|
||||
// - a muted dot with a tooltip on a MARKED row only, where the body is one
|
||||
// snippet among many (backlinks, the command palette, the conventions and
|
||||
// playbooks lists). An unmarked row renders exactly as before.
|
||||
//
|
||||
// The marker is a signal to re-read later. Nothing here re-fetches or
|
||||
// re-sends.
|
||||
|
||||
/** The one wording every treatment uses. */
|
||||
export const STALE_BODY_NOTICE = 'This may not include the latest edits.';
|
||||
|
||||
/** True when the server marked this row's body as behind the live document. */
|
||||
export function isBodyStale(row: { content_state?: string | null } | null | undefined): boolean {
|
||||
return row?.content_state === 'applied_pending_flush';
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// BUG-3050 U3 (codex round 1): a site that builds an item from a server row
|
||||
// but keeps a DIFFERENT body must keep that body's `content_state` too, or the
|
||||
// renders that read the marker label a body it does not describe. Two shapes:
|
||||
//
|
||||
// - the local body kept over a fresh row: `{ ...row, content: item.content }`
|
||||
// must carry `content_state: item.content_state`;
|
||||
// - a blanked body built from an index row, which still carries the row's
|
||||
// marker: `{ ...row, content: '' }` must clear it, since the server emits the
|
||||
// marker only where a real body is.
|
||||
//
|
||||
// The scan finds every object literal that spreads something and sets
|
||||
// `content`, in the files that hold the population (census on the BUG-3050
|
||||
// trail, round 1). It fails naming the literal.
|
||||
|
||||
const SRC = resolve(__dirname, '..');
|
||||
const FILES = [
|
||||
'components/items/ItemDetail.svelte',
|
||||
'components/search/CommandPalette.svelte',
|
||||
'stores/localIndex.svelte.ts',
|
||||
'../routes/[username]/[workspace]/[collection]/+page.svelte',
|
||||
];
|
||||
|
||||
function spreadsSettingContent(source: string): string[] {
|
||||
const out: string[] = [];
|
||||
const re = /\{\s*\.\.\.[^{}]*?\bcontent\s*:[^{}]*\}/gs;
|
||||
for (const m of source.matchAll(re)) out.push(m[0].replace(/\s+/g, ' '));
|
||||
return out;
|
||||
}
|
||||
|
||||
function violation(literal: string): string | null {
|
||||
const content = /\bcontent\s*:\s*([^,}]+)/.exec(literal)?.[1].trim();
|
||||
if (content === undefined) return null;
|
||||
// The row's own body travels with its own marker: nothing to carry.
|
||||
if (/^\w+\.content$/.test(content) && literal.includes(`...${content.split('.')[0]}`)) return null;
|
||||
if (content === "''" || content === '""') {
|
||||
return /content_state\s*:\s*undefined/.test(literal) ? null : 'blank body keeps the row\'s marker';
|
||||
}
|
||||
const owner = /^(\w+)\??\.content$/.exec(content)?.[1];
|
||||
if (owner && new RegExp(`content_state\\s*:\\s*${owner}\\??\\.content_state`).test(literal)) return null;
|
||||
return `kept body ${content} without its content_state`;
|
||||
}
|
||||
|
||||
describe('a kept or blanked body carries the marker that describes it (BUG-3050 U3)', () => {
|
||||
it('the scan sees the population it was written for', () => {
|
||||
const all = FILES.flatMap((f) => spreadsSettingContent(readFileSync(resolve(SRC, f), 'utf8')));
|
||||
// 5 kept-body sites in ItemDetail + 3 blanked index rows.
|
||||
expect(all.length).toBe(8);
|
||||
});
|
||||
|
||||
for (const f of FILES) {
|
||||
it(`${f}: every such literal carries the right marker`, () => {
|
||||
const bad = spreadsSettingContent(readFileSync(resolve(SRC, f), 'utf8'))
|
||||
.map((l) => [l, violation(l)] as const)
|
||||
.filter(([, v]) => v !== null)
|
||||
.map(([l, v]) => `${v}: ${l}`);
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it('the checker refuses each broken shape (negative controls)', () => {
|
||||
expect(violation("{ ...updated, content: item.content }")).not.toBeNull();
|
||||
expect(violation("{ ...row, content: '' }")).not.toBeNull();
|
||||
expect(violation("{ ...updated, content: item.content, content_state: updated.content_state }")).not.toBeNull();
|
||||
expect(violation("{ ...updated, content: item.content, content_state: item.content_state }")).toBeNull();
|
||||
expect(violation("{ ...row, content: '', content_state: undefined }")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { flushSync, mount, unmount, tick } from 'svelte';
|
||||
import type { Backlink, Version } from '$lib/types';
|
||||
|
||||
// BUG-3050 U3: a body the server marked as behind the live document
|
||||
// (`content_state: applied_pending_flush`, BUG-3000) says so wherever it is
|
||||
// rendered. Two treatments, ruled on the trail: a one-line notice above a body
|
||||
// that is the subject of the view, and a muted dot on a MARKED row where the
|
||||
// body is one snippet among many. An unmarked body renders nothing new.
|
||||
//
|
||||
// Every leg mounts the real component with a marked AND an unmarked input, so
|
||||
// a render that ignores the marker and a render that marks everything both
|
||||
// fail. The page bindings (share pages, the item pane, the lists, the palette)
|
||||
// are covered in e2e/bug-3050-stale-body-renders.spec.ts.
|
||||
|
||||
const { backlinkRows } = vi.hoisted(() => ({ backlinkRows: { value: [] as unknown[] } }));
|
||||
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
api: {
|
||||
items: { backlinks: vi.fn(async () => backlinkRows.value) },
|
||||
versions: { restore: vi.fn(), get: vi.fn(async () => ({ content: '' })) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/auth.svelte', () => ({
|
||||
authStore: { identityFence: () => () => true, user: null },
|
||||
}));
|
||||
|
||||
import { isBodyStale, STALE_BODY_NOTICE } from './staleBody';
|
||||
import { parsePublicItem } from '$lib/components/share/shareView';
|
||||
import PublicItemExpansion from '$lib/components/share/PublicItemExpansion.svelte';
|
||||
import TimelineVersionCard from '$lib/components/timeline/TimelineVersionCard.svelte';
|
||||
import BacklinksPanel from '$lib/components/BacklinksPanel.svelte';
|
||||
|
||||
const MARK = 'applied_pending_flush';
|
||||
const mounted: { root: HTMLElement; instance: ReturnType<typeof mount> }[] = [];
|
||||
|
||||
function render<P extends Record<string, unknown>>(component: unknown, props: P): HTMLElement {
|
||||
const root = document.body.appendChild(document.createElement('div'));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = mount(component as any, { target: root, props });
|
||||
flushSync();
|
||||
mounted.push({ root, instance });
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const m of mounted.splice(0)) {
|
||||
unmount(m.instance);
|
||||
m.root.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe('isBodyStale', () => {
|
||||
it('is true only for the server marker', () => {
|
||||
expect(isBodyStale({ content_state: MARK })).toBe(true);
|
||||
expect(isBodyStale({ content_state: '' })).toBe(false);
|
||||
expect(isBodyStale({ content_state: 'something_else' })).toBe(false);
|
||||
expect(isBodyStale({})).toBe(false);
|
||||
expect(isBodyStale(null)).toBe(false);
|
||||
expect(isBodyStale(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('share: parsePublicItem carries the marker (C2)', () => {
|
||||
it('copies content_state into contentStale', () => {
|
||||
expect(parsePublicItem({ title: 't', content: 'b', content_state: MARK }).contentStale).toBe(true);
|
||||
expect(parsePublicItem({ title: 't', content: 'b' }).contentStale).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('share: the inline expansion shows the notice on a marked body (C3)', () => {
|
||||
function expansion(stale: boolean) {
|
||||
const item = parsePublicItem({ title: 't', content: 'body', ...(stale ? { content_state: MARK } : {}) });
|
||||
return render(PublicItemExpansion, { item, fields: [], html: '<p>body</p>' });
|
||||
}
|
||||
it('marked: the notice precedes the body', () => {
|
||||
const root = expansion(true);
|
||||
const notice = root.querySelector('[data-testid="stale-body-notice"]');
|
||||
expect(notice?.textContent).toBe(STALE_BODY_NOTICE);
|
||||
const body = root.querySelector('.expansion-content')!;
|
||||
expect(notice!.compareDocumentPosition(body) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
it('unmarked: no notice', () => {
|
||||
expect(expansion(false).querySelector('[data-testid="stale-body-notice"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeline: the diff\'s current side says it may be behind (C5)', () => {
|
||||
const version = {
|
||||
id: 'v1',
|
||||
item_id: 'i1',
|
||||
content: 'old',
|
||||
is_diff: false,
|
||||
change_summary: 'edited',
|
||||
created_by: 'user',
|
||||
source: 'web',
|
||||
created_at: '2026-07-20T00:00:00Z',
|
||||
} as unknown as Version;
|
||||
function card(currentContentStale: boolean) {
|
||||
const root = render(TimelineVersionCard, { version, wsSlug: 'ws', itemSlug: 'ITEM-1', currentContent: 'now', currentContentStale });
|
||||
(root.querySelector('.card-header') as HTMLButtonElement).click();
|
||||
flushSync();
|
||||
return root;
|
||||
}
|
||||
it('marked: the notice is shown with the diff', () => {
|
||||
const root = card(true);
|
||||
expect(root.querySelector('[data-testid="stale-body-notice"]')?.textContent).toBe(STALE_BODY_NOTICE);
|
||||
});
|
||||
it('unmarked: no notice', () => {
|
||||
const root = card(false);
|
||||
expect(root.querySelector('.diff-container')).not.toBeNull();
|
||||
expect(root.querySelector('[data-testid="stale-body-notice"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backlinks: a dot on MARKED rows only (C8)', () => {
|
||||
function row(id: string, title: string, stale: boolean): Backlink {
|
||||
return {
|
||||
source_item_id: id,
|
||||
source_ref: `DOC-${id}`,
|
||||
source_title: title,
|
||||
source_slug: `s-${id}`,
|
||||
source_collection_slug: 'docs',
|
||||
source_collection_name: 'Docs',
|
||||
source_collection_icon: '',
|
||||
snippet: `...links to it from ${title}...`,
|
||||
updated_at: '2026-09-26T00:00:00Z',
|
||||
...(stale ? { content_state: MARK } : {}),
|
||||
} as unknown as Backlink;
|
||||
}
|
||||
it('marks the marked row and leaves its neighbour alone', async () => {
|
||||
backlinkRows.value = [row('1', 'Marked', true), row('2', 'Plain', false)];
|
||||
const root = render(BacklinksPanel, { wsSlug: 'ws', username: 'u', itemSlug: 'target', itemId: 't1' });
|
||||
await vi.waitFor(() => expect(root.querySelectorAll('.snippet').length).toBe(2));
|
||||
await tick();
|
||||
const dots = root.querySelectorAll('[data-testid="stale-body-dot"]');
|
||||
expect(dots.length).toBe(1);
|
||||
expect(dots[0].closest('li')?.textContent).toContain('Marked');
|
||||
expect(dots[0].getAttribute('title')).toBe(STALE_BODY_NOTICE);
|
||||
expect(dots[0].getAttribute('aria-label')).toBe(STALE_BODY_NOTICE);
|
||||
});
|
||||
});
|
||||
@@ -1635,7 +1635,7 @@ export const localIndex = {
|
||||
// content:'' — link resolution/picker never read the body;
|
||||
// the skinny index rows don't carry it. Matches how the
|
||||
// collection page adapts getByCollection rows to Item.
|
||||
out.push({ ...row, content: '' } as Item);
|
||||
out.push({ ...row, content: '', content_state: undefined } as Item);
|
||||
}
|
||||
out.sort((a, b) => {
|
||||
if (a.updated_at !== b.updated_at) {
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface PublicShareItem {
|
||||
ref?: string;
|
||||
fields?: string;
|
||||
content?: string;
|
||||
/** BUG-3000 marker: `content` is behind the live document (BUG-3050 U3). */
|
||||
content_state?: 'applied_pending_flush';
|
||||
}
|
||||
|
||||
/** The shape returned by GET /api/v1/s/{token}. Auth/password gates short-circuit
|
||||
@@ -133,6 +135,8 @@ export interface SharePayload {
|
||||
item_ref?: string;
|
||||
collection_name?: string;
|
||||
collection_icon?: string;
|
||||
/** BUG-3000 marker: `content` is behind the live document (BUG-3050 U3). */
|
||||
content_state?: 'applied_pending_flush';
|
||||
};
|
||||
collection?: PublicShareCollection;
|
||||
items?: PublicShareItem[];
|
||||
@@ -1528,11 +1532,8 @@ export interface Backlink {
|
||||
* It describes the SOURCE of each link, never the item the panel is about,
|
||||
* so a list can mix marked and unmarked rows.
|
||||
*
|
||||
* NOTE: the API carries this; no browser surface RENDERS it yet. Where a
|
||||
* stale marker belongs in a backlinks panel or a command-palette result is
|
||||
* a UI decision rather than a field copy, tracked as BUG-3050. This
|
||||
* declaration exists so a consumer can see the field rather than discover
|
||||
* it in a network tab.
|
||||
* BacklinksPanel renders it as a muted dot on the marked row only, beside
|
||||
* the snippet (BUG-3050 U3, `lib/items/staleBody.ts`).
|
||||
*/
|
||||
content_state?: 'applied_pending_flush';
|
||||
updated_at: string;
|
||||
|
||||
@@ -312,7 +312,7 @@
|
||||
wsSlug && collSlug
|
||||
? localIndex
|
||||
.getByCollection(wsSlug, collSlug, { includeArchived: showArchived })
|
||||
.map((row) => ({ ...row, content: '' }) as Item)
|
||||
.map((row) => ({ ...row, content: '', content_state: undefined }) as Item)
|
||||
: [],
|
||||
);
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
import Button from '$lib/components/common/Button.svelte';
|
||||
import PageHeader from '$lib/components/common/PageHeader.svelte';
|
||||
import EmptyState from '$lib/components/common/EmptyState.svelte';
|
||||
import StaleBodyDot from '$lib/components/common/StaleBodyDot.svelte';
|
||||
import { isBodyStale } from '$lib/items/staleBody';
|
||||
|
||||
const TRIGGERS = ['always','on-task-start','on-task-complete','on-implement','on-commit','on-pr-create','on-plan-start','on-plan-complete','on-plan'] as const;
|
||||
type Trigger = typeof TRIGGERS[number];
|
||||
@@ -665,6 +667,7 @@
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
<span class="row-title">{item.title}</span>
|
||||
{#if isBodyStale(item)}<StaleBodyDot />{/if}
|
||||
{#if convention.category}
|
||||
<span class="badge category-badge">{convention.category}</span>
|
||||
{/if}
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
import PageHeader from '$lib/components/common/PageHeader.svelte';
|
||||
import EmptyState from '$lib/components/common/EmptyState.svelte';
|
||||
import PlaybookFormFields from '$lib/components/playbooks/PlaybookFormFields.svelte';
|
||||
import StaleBodyDot from '$lib/components/common/StaleBodyDot.svelte';
|
||||
import { isBodyStale } from '$lib/items/staleBody';
|
||||
import {
|
||||
PLAYBOOK_SKELETON_BODY,
|
||||
argumentsToJSON,
|
||||
@@ -603,6 +605,7 @@
|
||||
<button class="card-header" onclick={() => toggleExpand(item.id)} aria-expanded={isExpanded}>
|
||||
<div class="card-title-row">
|
||||
<span class="card-title" class:deprecated-title={status === 'deprecated'}>{item.title}</span>
|
||||
{#if isBodyStale(item)}<StaleBodyDot />{/if}
|
||||
{#if status}<Chip size="sm" color={statusColor(status)}>{statusLabel(status)}</Chip>{/if}
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import { renderAttachmentUnavailable } from '$lib/markdown/attachments';
|
||||
import DOMPurify from 'dompurify';
|
||||
import PublicCollectionView from '$lib/components/share/PublicCollectionView.svelte';
|
||||
import StaleBodyNotice from '$lib/components/common/StaleBodyNotice.svelte';
|
||||
import { isBodyStale } from '$lib/items/staleBody';
|
||||
import {
|
||||
parsePublicCollection,
|
||||
parsePublicItems,
|
||||
@@ -57,6 +59,8 @@
|
||||
title: string;
|
||||
fields: Record<string, any>;
|
||||
content: string;
|
||||
/** The server's BUG-3000 marker on `content` (BUG-3050 U3). */
|
||||
contentStale: boolean;
|
||||
collection_name?: string;
|
||||
collection_icon?: string;
|
||||
item_ref?: string;
|
||||
@@ -420,6 +424,7 @@
|
||||
title: data.item?.title ?? 'Untitled',
|
||||
fields,
|
||||
content: data.item?.content ?? '',
|
||||
contentStale: isBodyStale(data.item),
|
||||
collection_name: data.item?.collection_name,
|
||||
collection_icon: data.item?.collection_icon,
|
||||
item_ref: data.item?.ref ?? data.item?.item_ref
|
||||
@@ -495,6 +500,7 @@
|
||||
title: data.item?.title ?? 'Untitled',
|
||||
fields,
|
||||
content: data.item?.content ?? '',
|
||||
contentStale: isBodyStale(data.item),
|
||||
collection_name: data.item?.collection_name,
|
||||
collection_icon: data.item?.collection_icon,
|
||||
item_ref: data.item?.ref ?? data.item?.item_ref
|
||||
@@ -605,6 +611,7 @@
|
||||
{/if}
|
||||
|
||||
{#if itemData.content}
|
||||
{#if itemData.contentStale}<StaleBodyNotice />{/if}
|
||||
<div class="item-content">
|
||||
{@html renderedContent}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user