From 76f822450affdbddc1742aec51f985978e6a7ec1 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:15:25 -0400 Subject: [PATCH 01/51] fix(content): fence Builder pagination refreshes --- .../actions/_database-source-utils.test.ts | 74 +++++ .../content/actions/_database-source-utils.ts | 299 +++++++++++++++--- .../content-database-source-actions.test.ts | 12 + .../refresh-content-database-source.ts | 45 ++- .../resync-content-database-source.db.test.ts | 151 +++++++++ .../editor/database/DatabaseView.tsx | 45 ++- ...ading-no-longer-restarts-from-the-begin.md | 6 + templates/content/shared/api.ts | 1 + 8 files changed, 559 insertions(+), 74 deletions(-) create mode 100644 templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md diff --git a/templates/content/actions/_database-source-utils.test.ts b/templates/content/actions/_database-source-utils.test.ts index 3d67522a57..44cf639ce5 100644 --- a/templates/content/actions/_database-source-utils.test.ts +++ b/templates/content/actions/_database-source-utils.test.ts @@ -33,6 +33,7 @@ import { builderAuthoritativeRawBodyHash, bulkChunkSizeForColumnCount, builderCmsEntryAlreadyRepresented, + builderCmsSourceContinuationIsCurrent, builderExecutionIsProvablyLocallyBlockedUnsent, canRefreshLocallyBlockedBuilderReview, buildMockBodyChange, @@ -100,6 +101,41 @@ function item(id: string, title: string): ContentDatabaseItem { } describe("database source helpers", () => { + it("accepts only the persisted Builder continuation offset", () => { + const metadataJson = JSON.stringify({ + sourceFetchState: "fetching", + lastReadHasMore: true, + lastReadNextOffset: 400, + activeReadSourceRowIds: ["entry-1"], + }); + + expect(builderCmsSourceContinuationIsCurrent(metadataJson, 400)).toBe(true); + expect(builderCmsSourceContinuationIsCurrent(metadataJson, 100)).toBe( + false, + ); + expect(builderCmsSourceContinuationIsCurrent(metadataJson, 0)).toBe(false); + expect( + builderCmsSourceContinuationIsCurrent( + JSON.stringify({ + sourceFetchState: "fetching", + lastReadHasMore: true, + lastReadNextOffset: 400, + }), + 400, + ), + ).toBe(false); + expect( + builderCmsSourceContinuationIsCurrent( + JSON.stringify({ + sourceFetchState: "idle", + lastReadHasMore: false, + lastReadNextOffset: 580, + }), + 400, + ), + ).toBe(false); + }); + it("selects document bodies only for explicitly heavy Builder snapshots", () => { expect(sourceSnapshotDocumentSelection(false)).not.toHaveProperty( "content", @@ -291,6 +327,44 @@ describe("database source helpers", () => { }); }); + it("clears the Builder refresh claim when read metadata is finalized", () => { + const metadata = JSON.parse( + serializeBuilderCmsSourceReadMetadataRecord({ + sourceTable: "agent-native-blog-article-test", + readState: "live", + entryCount: 580, + matchedRowCount: 580, + existingMetadataJson: JSON.stringify({ + builderContinuationClaimId: "claim-1", + builderContinuationClaimOffset: 400, + }), + completedBuilderContinuationClaimId: "claim-1", + }), + ); + + expect(metadata).not.toHaveProperty("builderContinuationClaimId"); + expect(metadata).not.toHaveProperty("builderContinuationClaimOffset"); + }); + + it("preserves a Builder refresh claim for unrelated metadata writers", () => { + const metadata = JSON.parse( + serializeBuilderCmsSourceReadMetadataRecord({ + sourceTable: "agent-native-blog-article-test", + readState: "live", + entryCount: 400, + matchedRowCount: 400, + existingMetadataJson: JSON.stringify({ + builderContinuationClaimId: "claim-1", + builderContinuationClaimOffset: 400, + builderContinuationClaimedAt: "2026-01-01T00:00:00.000Z", + }), + }), + ); + + expect(metadata.builderContinuationClaimId).toBe("claim-1"); + expect(metadata.builderContinuationClaimOffset).toBe(400); + }); + it("preserves existing Builder model fields during metadata rewrites", () => { const existingMetadataJson = JSON.stringify({ builderModelFields: [ diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index ae77298c4a..e2eaf8026a 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -162,6 +162,9 @@ type SourceMetadataRecord = { lastReadSuspiciousEmpty?: boolean; activeReadSourceRowIds?: string[]; sourceFetchState?: "idle" | "fetching" | "error" | string; + builderContinuationClaimId?: string; + builderContinuationClaimOffset?: number; + builderContinuationClaimedAt?: string; allowDraftWrites?: boolean; allowPublishWrites?: boolean; allowedWriteModes?: ContentDatabaseSourcePushMode[]; @@ -183,6 +186,145 @@ function parseObject( } } +export function builderCmsSourceContinuationIsCurrent( + metadataJson: string | null | undefined, + expectedOffset: number, +) { + const metadata = parseObject(metadataJson); + return ( + metadata?.sourceFetchState === "fetching" && + metadata.lastReadHasMore === true && + expectedOffset > 0 && + metadata.lastReadNextOffset === expectedOffset && + Array.isArray(metadata.activeReadSourceRowIds) && + metadata.activeReadSourceRowIds.length > 0 && + metadata.activeReadSourceRowIds.every((id) => typeof id === "string") + ); +} + +// Content's hosted request wall is 75 seconds. Keep the orphan-recovery lease +// well beyond that so an expired owner cannot still be executing mutations. +export const BUILDER_CMS_REFRESH_CLAIM_LEASE_MS = 30 * 60 * 1000; + +export async function claimBuilderCmsSourceRefresh(args: { + source: ContentDatabaseSourceRowDb; + expectedOffset?: number; + now?: string; +}) { + const metadata = + parseObject(args.source.metadataJson) ?? {}; + const now = args.now ?? new Date().toISOString(); + const claimedAt = metadata.builderContinuationClaimedAt + ? Date.parse(metadata.builderContinuationClaimedAt) + : Number.NaN; + const activeClaim = + !!metadata.builderContinuationClaimId && + Number.isFinite(claimedAt) && + Date.parse(now) - claimedAt < BUILDER_CMS_REFRESH_CLAIM_LEASE_MS; + if ( + activeClaim || + (args.expectedOffset !== undefined && + !builderCmsSourceContinuationIsCurrent( + args.source.metadataJson, + args.expectedOffset, + )) + ) { + return null; + } + + const claimId = nanoid(); + const claimedMetadataJson = JSON.stringify({ + ...metadata, + builderContinuationClaimId: claimId, + builderContinuationClaimOffset: args.expectedOffset, + builderContinuationClaimedAt: now, + }); + const [claimed] = await getDb() + .update(schema.contentDatabaseSources) + .set({ metadataJson: claimedMetadataJson }) + .where( + and( + eq(schema.contentDatabaseSources.id, args.source.id), + args.source.metadataJson === null + ? isNull(schema.contentDatabaseSources.metadataJson) + : eq( + schema.contentDatabaseSources.metadataJson, + args.source.metadataJson, + ), + ), + ) + .returning(); + return claimed ? { source: claimed, claimId } : null; +} + +export async function releaseBuilderCmsSourceRefreshClaim(args: { + sourceId: string; + claimId: string; +}) { + const db = getDb(); + for (let attempt = 0; attempt < 5; attempt += 1) { + const [current] = await db + .select({ metadataJson: schema.contentDatabaseSources.metadataJson }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, args.sourceId)) + .limit(1); + const metadata = parseObject(current?.metadataJson); + if (!metadata || metadata.builderContinuationClaimId !== args.claimId) { + return false; + } + delete metadata.builderContinuationClaimId; + delete metadata.builderContinuationClaimOffset; + delete metadata.builderContinuationClaimedAt; + const nextMetadataJson = JSON.stringify(metadata); + const released = await db + .update(schema.contentDatabaseSources) + .set({ metadataJson: nextMetadataJson }) + .where( + and( + eq(schema.contentDatabaseSources.id, args.sourceId), + eq(schema.contentDatabaseSources.metadataJson, current.metadataJson!), + ), + ) + .returning({ id: schema.contentDatabaseSources.id }); + if (released.length > 0) return true; + } + return false; +} + +export async function renewBuilderCmsSourceRefreshClaim(args: { + sourceId: string; + claimId: string; + now?: string; +}) { + const db = getDb(); + const now = args.now ?? new Date().toISOString(); + for (let attempt = 0; attempt < 5; attempt += 1) { + const [current] = await db + .select({ metadataJson: schema.contentDatabaseSources.metadataJson }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, args.sourceId)) + .limit(1); + const metadata = parseObject(current?.metadataJson); + if (!metadata || metadata.builderContinuationClaimId !== args.claimId) { + return false; + } + metadata.builderContinuationClaimedAt = now; + const nextMetadataJson = JSON.stringify(metadata); + const renewed = await db + .update(schema.contentDatabaseSources) + .set({ metadataJson: nextMetadataJson }) + .where( + and( + eq(schema.contentDatabaseSources.id, args.sourceId), + eq(schema.contentDatabaseSources.metadataJson, current.metadataJson!), + ), + ) + .returning({ id: schema.contentDatabaseSources.id }); + if (renewed.length > 0) return true; + } + return false; +} + function parseArray(value: string | null | undefined): T[] { if (!value) return []; try { @@ -3941,11 +4083,12 @@ export function serializeBuilderCmsSourceReadMetadataRecord(args: { suspiciousEmpty?: boolean; builderModelFields?: BuilderCmsModelFieldSummary[]; existingMetadataJson?: string | null; + completedBuilderContinuationClaimId?: string; }) { const existingMetadata = parseObject( args.existingMetadataJson, ); - return JSON.stringify({ + const metadata: SourceMetadataRecord = { ...builderCmsSourceMetadata(args.sourceTable), ...existingMetadata, builderModelFields: @@ -3970,7 +4113,17 @@ export function serializeBuilderCmsSourceReadMetadataRecord(args: { : args.readState === "error" ? "error" : "idle"), - }); + }; + if ( + args.completedBuilderContinuationClaimId && + existingMetadata?.builderContinuationClaimId === + args.completedBuilderContinuationClaimId + ) { + delete metadata.builderContinuationClaimId; + delete metadata.builderContinuationClaimOffset; + delete metadata.builderContinuationClaimedAt; + } + return JSON.stringify(metadata); } export function serializeSourceCapabilitiesRecord( @@ -5339,6 +5492,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { source: ContentDatabaseSourceRowDb; now: string; runFullRefresh?: boolean; + refreshClaimId?: string; }) { let { properties, response } = await sourceSetupPayload(args.database.id); const db = getDb(); @@ -5401,6 +5555,17 @@ export async function resyncBuilderCmsSourceSnapshot(args: { (builderRead.progress?.startOffset ?? 0) > 0); const builderEntries = builderRead.state === "live" ? builderRead.entries : []; + if ( + args.refreshClaimId && + !(await renewBuilderCmsSourceRefreshClaim({ + sourceId: args.source.id, + claimId: args.refreshClaimId, + })) + ) { + throw new Error( + "Builder source refresh claim was lost before snapshot mutation.", + ); + } let existingRows = await db .select() .from(schema.contentDatabaseSourceRows) @@ -5432,6 +5597,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { sourceFetchState: "error", syncState: "error", suspiciousEmpty: true, + refreshClaimId: args.refreshClaimId, }); return; } @@ -5597,6 +5763,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { sourceFetchState: hasMore ? "fetching" : "idle", syncState: hasMore ? "refreshing" : "idle", activeReadSourceRowIds: hasMore ? nextActiveReadSourceRowIds : undefined, + refreshClaimId: args.refreshClaimId, }); return; } @@ -5711,6 +5878,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { sourceFetchState: builderRead.state === "error" ? "error" : "idle", activeReadSourceRowIds: undefined, syncState: "idle", + refreshClaimId: args.refreshClaimId, }); } @@ -6053,58 +6221,87 @@ export async function updateBuilderCmsSourceReadMetadata(args: { syncState?: ContentDatabaseSourceSyncState; builderModelFields?: BuilderCmsModelFieldSummary[]; suspiciousEmpty?: boolean; + refreshClaimId?: string; }) { const db = getDb(); - const [currentSource] = await db - .select({ - capabilitiesJson: schema.contentDatabaseSources.capabilitiesJson, - metadataJson: schema.contentDatabaseSources.metadataJson, - lastSourceUpdatedAt: schema.contentDatabaseSources.lastSourceUpdatedAt, - }) - .from(schema.contentDatabaseSources) - .where(eq(schema.contentDatabaseSources.id, args.sourceId)) - .limit(1); - const nextJson = mergeBuilderCmsWriteSettingsIntoJson({ - sourceTable: args.sourceTable, - currentCapabilitiesJson: currentSource?.capabilitiesJson, - currentMetadataJson: currentSource?.metadataJson, - nextCapabilitiesJson: sourceCapabilitiesForType("builder-cms"), - nextMetadataJson: serializeBuilderCmsSourceReadMetadataRecord({ + for (let attempt = 0; attempt < 5; attempt += 1) { + const [currentSource] = await db + .select({ + capabilitiesJson: schema.contentDatabaseSources.capabilitiesJson, + metadataJson: schema.contentDatabaseSources.metadataJson, + lastSourceUpdatedAt: schema.contentDatabaseSources.lastSourceUpdatedAt, + }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, args.sourceId)) + .limit(1); + if (!currentSource) throw new Error("Builder source not found."); + const currentMetadata = parseObject( + currentSource.metadataJson, + ); + if ( + args.refreshClaimId && + currentMetadata?.builderContinuationClaimId !== args.refreshClaimId + ) { + throw new Error( + "Builder source refresh claim was lost before completion.", + ); + } + const nextJson = mergeBuilderCmsWriteSettingsIntoJson({ sourceTable: args.sourceTable, - readState: args.readState, - entryCount: args.entryCount, - matchedRowCount: args.matchedRowCount, - progress: args.progress, - sourceFetchState: args.sourceFetchState, - activeReadSourceRowIds: args.activeReadSourceRowIds, - suspiciousEmpty: args.suspiciousEmpty, - builderModelFields: args.builderModelFields, - existingMetadataJson: currentSource?.metadataJson, - }), - }); - await db - .update(schema.contentDatabaseSources) - .set({ - syncState: args.syncState ?? "linked", - freshness: - args.readState === "error" || - args.progress?.partial || - args.suspiciousEmpty - ? "stale" - : "fresh", - capabilitiesJson: nextJson.capabilitiesJson, - metadataJson: nextJson.metadataJson, - lastRefreshedAt: args.now, - lastSourceUpdatedAt: args.suspiciousEmpty - ? (currentSource?.lastSourceUpdatedAt ?? null) - : args.fetchedAt, - lastError: - args.readState === "error" || args.suspiciousEmpty - ? args.message - : null, - updatedAt: args.now, - }) - .where(eq(schema.contentDatabaseSources.id, args.sourceId)); + currentCapabilitiesJson: currentSource.capabilitiesJson, + currentMetadataJson: currentSource.metadataJson, + nextCapabilitiesJson: sourceCapabilitiesForType("builder-cms"), + nextMetadataJson: serializeBuilderCmsSourceReadMetadataRecord({ + sourceTable: args.sourceTable, + readState: args.readState, + entryCount: args.entryCount, + matchedRowCount: args.matchedRowCount, + progress: args.progress, + sourceFetchState: args.sourceFetchState, + activeReadSourceRowIds: args.activeReadSourceRowIds, + suspiciousEmpty: args.suspiciousEmpty, + builderModelFields: args.builderModelFields, + existingMetadataJson: currentSource.metadataJson, + completedBuilderContinuationClaimId: args.refreshClaimId, + }), + }); + const updated = await db + .update(schema.contentDatabaseSources) + .set({ + syncState: args.syncState ?? "linked", + freshness: + args.readState === "error" || + args.progress?.partial || + args.suspiciousEmpty + ? "stale" + : "fresh", + capabilitiesJson: nextJson.capabilitiesJson, + metadataJson: nextJson.metadataJson, + lastRefreshedAt: args.now, + lastSourceUpdatedAt: args.suspiciousEmpty + ? currentSource.lastSourceUpdatedAt + : args.fetchedAt, + lastError: + args.readState === "error" || args.suspiciousEmpty + ? args.message + : null, + updatedAt: args.now, + }) + .where( + and( + eq(schema.contentDatabaseSources.id, args.sourceId), + currentSource.metadataJson === null + ? isNull(schema.contentDatabaseSources.metadataJson) + : eq( + schema.contentDatabaseSources.metadataJson, + currentSource.metadataJson, + ), + ), + ) + .returning({ id: schema.contentDatabaseSources.id }); + if (updated.length > 0) return; + } + throw new Error("Builder source metadata changed repeatedly during refresh."); } export async function updateReadOnlySourceMetadata(args: { diff --git a/templates/content/actions/content-database-source-actions.test.ts b/templates/content/actions/content-database-source-actions.test.ts index c7cfd2fa11..b25876d6c6 100644 --- a/templates/content/actions/content-database-source-actions.test.ts +++ b/templates/content/actions/content-database-source-actions.test.ts @@ -373,6 +373,18 @@ describe("content database source actions", () => { }); }); + it("accepts a guarded Builder continuation offset", () => { + expect( + refreshSource.schema.parse({ + databaseId: "database", + expectedBuilderContinuationOffset: 400, + }), + ).toEqual({ + databaseId: "database", + expectedBuilderContinuationOffset: 400, + }); + }); + it("caps content database page sizes for bounded responses", () => { expect( normalizeContentDatabasePageOptions({ limit: 1, offset: -5 }), diff --git a/templates/content/actions/refresh-content-database-source.ts b/templates/content/actions/refresh-content-database-source.ts index 236eeff4b1..5d576815be 100644 --- a/templates/content/actions/refresh-content-database-source.ts +++ b/templates/content/actions/refresh-content-database-source.ts @@ -5,9 +5,11 @@ import { z } from "zod"; import type { ContentDatabaseSourceStatusResponse } from "../shared/api.js"; import { getContentDatabaseSourceAdapter } from "./_content-database-source-adapters.js"; import { + claimBuilderCmsSourceRefresh, getContentDatabaseSourceSnapshot, getContentDatabaseSourceSnapshotById, getExistingSourceForWrite, + releaseBuilderCmsSourceRefreshClaim, resyncBuilderCmsSourceSnapshot, resyncMockSourceSnapshot, resolveDatabaseForSourceMutation, @@ -33,6 +35,14 @@ export default defineAction({ .describe( "For paginated Builder CMS or Notion sources, read a bounded multi-page snapshot in this refresh.", ), + expectedBuilderContinuationOffset: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Internal Builder pagination guard. Continue only when the persisted source still expects this offset.", + ), }), run: async (args): Promise => { const database = await resolveDatabaseForSourceMutation(args); @@ -50,15 +60,30 @@ export default defineAction({ } const now = new Date().toISOString(); + let skippedOverlappingBuilderRefresh = false; if (source.sourceType === "mock-local") { await resyncMockSourceSnapshot({ database, source, now }); } else if (source.sourceType === "builder-cms") { - await resyncBuilderCmsSourceSnapshot({ - database, + const claimedSource = await claimBuilderCmsSourceRefresh({ source, - now, - runFullRefresh: args.fullRefresh === true, + expectedOffset: args.expectedBuilderContinuationOffset, }); + skippedOverlappingBuilderRefresh = !claimedSource; + if (claimedSource) { + await resyncBuilderCmsSourceSnapshot({ + database, + source: claimedSource.source, + now, + runFullRefresh: args.fullRefresh === true, + refreshClaimId: claimedSource.claimId, + }).catch(async (error: unknown) => { + await releaseBuilderCmsSourceRefreshClaim({ + sourceId: source.id, + claimId: claimedSource.claimId, + }); + throw error; + }); + } } else if (source.sourceType === "local-table") { // Read-only federated secondary; its rows are re-read on demand, nothing // to resync against the primary's local snapshot here. @@ -116,11 +141,13 @@ export default defineAction({ database: serializeDatabase(database), mode: "source-backed", summary: snapshot - ? builderFetching - ? `${snapshot.sourceName} fetched ${builderFetched ?? "some"} rows. Run refresh again to continue loading the remaining Builder rows.` - : snapshot.sourceType === "notion-database" - ? `${snapshot.sourceName} refreshed read-only from Notion.` - : `${snapshot.sourceName} resynced locally; field mappings and row identity now reflect the current database snapshot.` + ? skippedOverlappingBuilderRefresh + ? `${snapshot.sourceName} already advanced or has another refresh in progress; the current snapshot was preserved.` + : builderFetching + ? `${snapshot.sourceName} fetched ${builderFetched ?? "some"} rows. Run refresh again to continue loading the remaining Builder rows.` + : snapshot.sourceType === "notion-database" + ? `${snapshot.sourceName} refreshed read-only from Notion.` + : `${snapshot.sourceName} resynced locally; field mappings and row identity now reflect the current database snapshot.` : "Source metadata refreshed.", source: snapshot, }; diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index 414edf893a..c4e39162ed 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -551,6 +551,10 @@ let seedSourceRows: typeof import("./_database-source-utils.js").seedMockSourceR let materializeSourceFields: typeof import("./_database-source-utils.js").materializeSourceFieldPropertyValues; let getSnapshot: typeof import("./_database-source-utils.js").getContentDatabaseSourceSnapshotById; let getWriteSnapshot: typeof import("./_database-source-utils.js").getContentDatabaseSourceSnapshotForWrite; +let claimSourceRefresh: typeof import("./_database-source-utils.js").claimBuilderCmsSourceRefresh; +let releaseSourceRefreshClaim: typeof import("./_database-source-utils.js").releaseBuilderCmsSourceRefreshClaim; +let renewSourceRefreshClaim: typeof import("./_database-source-utils.js").renewBuilderCmsSourceRefreshClaim; +let updateSourceReadMetadata: typeof import("./_database-source-utils.js").updateBuilderCmsSourceReadMetadata; let hydrateQueuedBodies: typeof import("./_database-source-utils.js").processBuilderBodyHydrationQueue; let withBuilderBodyValues: typeof import("./_database-source-utils.js").withBuilderBodySourceValues; let seedCollabFromText: typeof import("@agent-native/core/collab").seedFromText; @@ -607,6 +611,14 @@ beforeAll(async () => { .getContentDatabaseSourceSnapshotById; getWriteSnapshot = (await import("./_database-source-utils.js")) .getContentDatabaseSourceSnapshotForWrite; + claimSourceRefresh = (await import("./_database-source-utils.js")) + .claimBuilderCmsSourceRefresh; + releaseSourceRefreshClaim = (await import("./_database-source-utils.js")) + .releaseBuilderCmsSourceRefreshClaim; + renewSourceRefreshClaim = (await import("./_database-source-utils.js")) + .renewBuilderCmsSourceRefreshClaim; + updateSourceReadMetadata = (await import("./_database-source-utils.js")) + .updateBuilderCmsSourceReadMetadata; hydrateQueuedBodies = (await import("./_database-source-utils.js")) .processBuilderBodyHydrationQueue; withBuilderBodyValues = (await import("./_database-source-utils.js")) @@ -628,6 +640,145 @@ afterAll(() => { } }); +it("atomically grants one Builder continuation claim per persisted offset", async () => { + const db = getDb(); + const now = new Date().toISOString(); + const metadataJson = JSON.stringify({ + sourceFetchState: "fetching", + lastReadHasMore: true, + lastReadNextOffset: 400, + activeReadSourceRowIds: ["entry-1"], + }); + await db.insert(schema.documents).values({ + id: "doc-continuation-claim", + ownerEmail: OWNER, + title: "Continuation claim", + content: "", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabases).values({ + id: "db-continuation-claim", + ownerEmail: OWNER, + documentId: "doc-continuation-claim", + title: "Continuation claim", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseSources).values({ + id: "source-continuation-claim", + ownerEmail: OWNER, + databaseId: "db-continuation-claim", + sourceType: "builder-cms", + sourceName: "Continuation claim", + sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, + syncState: "refreshing", + freshness: "stale", + metadataJson, + createdAt: now, + updatedAt: now, + }); + const [source] = await db + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, "source-continuation-claim")); + + const claims = await Promise.all([ + claimSourceRefresh({ source, expectedOffset: 400, now }), + claimSourceRefresh({ source, expectedOffset: 400, now }), + ]); + const granted = claims.filter((claim) => claim !== null); + expect(granted).toHaveLength(1); + expect( + JSON.parse(granted[0]!.source.metadataJson!).builderContinuationClaimOffset, + ).toBe(400); + + const claimedMetadata = JSON.parse(granted[0]!.source.metadataJson!); + claimedMetadata.writeMode = "publish_updates"; + await db + .update(schema.contentDatabaseSources) + .set({ metadataJson: JSON.stringify(claimedMetadata) }) + .where(eq(schema.contentDatabaseSources.id, source.id)); + await releaseSourceRefreshClaim({ + sourceId: source.id, + claimId: granted[0]!.claimId, + }); + const [released] = await db + .select({ metadataJson: schema.contentDatabaseSources.metadataJson }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, source.id)); + const releasedMetadata = JSON.parse(released.metadataJson!); + expect(releasedMetadata.writeMode).toBe("publish_updates"); + expect(releasedMetadata).not.toHaveProperty("builderContinuationClaimId"); + + const orphanedMetadataJson = JSON.stringify({ + ...releasedMetadata, + builderContinuationClaimId: "orphaned-claim", + builderContinuationClaimOffset: 400, + builderContinuationClaimedAt: "2026-01-01T00:00:00.000Z", + }); + await db + .update(schema.contentDatabaseSources) + .set({ metadataJson: orphanedMetadataJson }) + .where(eq(schema.contentDatabaseSources.id, source.id)); + const [orphanedSource] = await db + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, source.id)); + const recovered = await claimSourceRefresh({ + source: orphanedSource, + expectedOffset: 400, + now: "2026-01-01T00:31:00.000Z", + }); + expect(recovered?.claimId).toBeTruthy(); + expect(recovered?.claimId).not.toBe("orphaned-claim"); + expect( + await renewSourceRefreshClaim({ + sourceId: source.id, + claimId: "orphaned-claim", + now: "2026-01-01T00:31:30.000Z", + }), + ).toBe(false); + await releaseSourceRefreshClaim({ + sourceId: source.id, + claimId: recovered!.claimId, + }); + + const [readySource] = await db + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, source.id)); + const fenced = await claimSourceRefresh({ + source: readySource, + expectedOffset: 400, + now: "2026-01-01T00:12:00.000Z", + }); + await expect( + updateSourceReadMetadata({ + sourceId: source.id, + sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, + readState: "live", + entryCount: 580, + matchedRowCount: 580, + fetchedAt: "2026-01-01T00:12:30.000Z", + now: "2026-01-01T00:12:30.000Z", + message: null, + refreshClaimId: "not-the-owner", + }), + ).rejects.toThrow("refresh claim was lost"); + const [stillFenced] = await db + .select({ metadataJson: schema.contentDatabaseSources.metadataJson }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, source.id)); + expect(JSON.parse(stillFenced.metadataJson!).builderContinuationClaimId).toBe( + fenced!.claimId, + ); + await releaseSourceRefreshClaim({ + sourceId: source.id, + claimId: fenced!.claimId, + }); +}); + it("preserves an established source snapshot when Builder unexpectedly returns zero entries", async () => { builderReadMock.mode = "full"; builderReadMock.calls = []; diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index b5148bcfb5..92fe914899 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -1115,12 +1115,20 @@ function DatabaseTable({ ), }; const runSourceRefresh = useCallback( - (sourceId: string, onError?: () => void) => { + ( + sourceId: string, + onError?: () => void, + expectedBuilderContinuationOffset?: number, + ) => { if (!acquireDatabaseSourceOperation(refreshSourceInFlightRef, sourceId)) { return false; } refreshSource.mutate( - { documentId: document.id, sourceId }, + { + documentId: document.id, + sourceId, + expectedBuilderContinuationOffset, + }, { onError, onSettled: () => { @@ -1197,10 +1205,13 @@ function DatabaseTable({ continuationKey, ); if ( - !runSourceRefresh(candidate.id, () => - setBuilderContinuationClientErrorKeys((current) => - addUniqueKey(current, continuationKey), - ), + !runSourceRefresh( + candidate.id, + () => + setBuilderContinuationClientErrorKeys((current) => + addUniqueKey(current, continuationKey), + ), + candidate.metadata.lastReadNextOffset, ) ) { autoContinueBuilderSourceRef.current.delete(continuationKey); @@ -1273,10 +1284,13 @@ function DatabaseTable({ return; } if ( - runSourceRefresh(candidate.id, () => - setBuilderContinuationClientErrorKeys((current) => - addUniqueKey(current, continuationKey), - ), + runSourceRefresh( + candidate.id, + () => + setBuilderContinuationClientErrorKeys((current) => + addUniqueKey(current, continuationKey), + ), + candidate.metadata.lastReadNextOffset, ) ) { builderContinuationWatchdogRef.current.set( @@ -2545,10 +2559,13 @@ function DatabaseTable({ continuationKey, ); builderContinuationWatchdogRef.current.set(continuationKey, 0); - runSourceRefresh(builderSource.id, () => - setBuilderContinuationClientErrorKeys((current) => - addUniqueKey(current, continuationKey), - ), + runSourceRefresh( + builderSource.id, + () => + setBuilderContinuationClientErrorKeys((current) => + addUniqueKey(current, continuationKey), + ), + builderSource.metadata.lastReadNextOffset, ); }} /> diff --git a/templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md b/templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md new file mode 100644 index 0000000000..10e8638870 --- /dev/null +++ b/templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-24 +--- + +Builder collection loading no longer restarts from the beginning after reaching the final page. diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 27455077f1..b580f6c405 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -957,6 +957,7 @@ export interface RefreshContentDatabaseSourceRequest { documentId?: string; sourceId?: string; fullRefresh?: boolean; + expectedBuilderContinuationOffset?: number; } export interface DisconnectContentDatabaseSourceRequest { From 36700f88ee818d0f38769858c9ee7e13ae1693b1 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:41:40 -0400 Subject: [PATCH 02/51] chore(content): redeploy Builder relay preview From 4094673d79a3779bf06fb380747106336829a193 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:43:26 -0400 Subject: [PATCH 03/51] docs(content): clarify Builder refresh fix --- ...lder-collection-loading-no-longer-restarts-from-the-begin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md b/templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md index 10e8638870..2a758ad319 100644 --- a/templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md +++ b/templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md @@ -3,4 +3,4 @@ type: fixed date: 2026-07-24 --- -Builder collection loading no longer restarts from the beginning after reaching the final page. +Builder collection loading no longer restarts from the beginning when overlapping refreshes finish out of order. From cdbb2ab5336b68669c3f55ff02c7eec5a58f41cf Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:54:34 -0400 Subject: [PATCH 04/51] fix(core): route Builder relay through immutable previews --- .changeset/quiet-owls-relay.md | 5 ++ .../core/src/server/builder-browser.spec.ts | 58 ++++++++++++++++++ packages/core/src/server/builder-browser.ts | 59 +++++++++++++++++++ .../core/src/server/core-routes-plugin.ts | 20 +++++-- ...ow-complete-securely-from-netlify-deplo.md | 6 ++ 5 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 .changeset/quiet-owls-relay.md create mode 100644 templates/content/changelog/2026-07-24-builder-connections-now-complete-securely-from-netlify-deplo.md diff --git a/.changeset/quiet-owls-relay.md b/.changeset/quiet-owls-relay.md new file mode 100644 index 0000000000..f21f172fd9 --- /dev/null +++ b/.changeset/quiet-owls-relay.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Route Builder authorization callbacks for Netlify previews through the immutable deployment URL while returning popup status to the visible preview. diff --git a/packages/core/src/server/builder-browser.spec.ts b/packages/core/src/server/builder-browser.spec.ts index 4976c0e819..01692fa5c0 100644 --- a/packages/core/src/server/builder-browser.spec.ts +++ b/packages/core/src/server/builder-browser.spec.ts @@ -25,6 +25,8 @@ import { getBuilderBrowserStatusForEvent, isBuilderBranchingEnabled, resolveBuilderCallbackReturnUrl, + resolveBuilderPreviewRelayParentOrigin, + resolveBuilderPreviewRelayTargetOrigin, runBuilderAgent, signBuilderConnectToken, signBuilderCallbackState, @@ -585,6 +587,62 @@ describe("Builder callback CSRF state", () => { ).toBeNull(); }); + it("uses the immutable Netlify deploy URL as the relay destination", () => { + process.env.DEPLOY_URL = + "https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app"; + + expect( + resolveBuilderPreviewRelayTargetOrigin( + "https://deploy-preview-2382--agent-native-content.netlify.app", + ), + ).toBe( + "https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app", + ); + }); + + it("rejects an immutable Netlify deploy URL for a different site", () => { + process.env.DEPLOY_URL = + "https://6a62ed72f518f00008436fa3--different-site.netlify.app"; + const previewOrigin = + "https://deploy-preview-2382--agent-native-content.netlify.app"; + + expect(resolveBuilderPreviewRelayTargetOrigin(previewOrigin)).toBe( + previewOrigin, + ); + }); + + it("leaves non-Netlify preview origins unchanged", () => { + process.env.DEPLOY_URL = + "https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app"; + const previewOrigin = "https://preview-example.builderio.xyz"; + + expect(resolveBuilderPreviewRelayTargetOrigin(previewOrigin)).toBe( + previewOrigin, + ); + }); + + it("keeps the visible preview opener separate from the immutable relay target", () => { + expect( + resolveBuilderPreviewRelayParentOrigin({ + openerOrigin: + "https://deploy-preview-2382--agent-native-content.netlify.app", + targetOrigin: + "https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app", + }), + ).toBe("https://deploy-preview-2382--agent-native-content.netlify.app"); + }); + + it("falls back to the signed relay target for an unsafe opener", () => { + const targetOrigin = + "https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app"; + expect( + resolveBuilderPreviewRelayParentOrigin({ + openerOrigin: "https://attacker.example", + targetOrigin, + }), + ).toBe(targetOrigin); + }); + it("returns users to the preview opener after a gateway callback", () => { process.env.NODE_ENV = "production"; process.env.AGENT_NATIVE_WORKSPACE = "1"; diff --git a/packages/core/src/server/builder-browser.ts b/packages/core/src/server/builder-browser.ts index da6a13fab8..3776bb5125 100644 --- a/packages/core/src/server/builder-browser.ts +++ b/packages/core/src/server/builder-browser.ts @@ -36,6 +36,10 @@ const BUILDER_RELAY_PURPOSE = "builder-preview-callback-relay"; const BUILDER_RELAY_STATE_VERSION = 1; const BUILDER_RELAY_TTL_MS = 10 * 60 * 1000; const BUILDER_RELAY_REQUEST_SKEW_MS = 2 * 60 * 1000; +const IMMUTABLE_NETLIFY_RELAY_HOST = + /^(?[a-f0-9]{24})--(?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.netlify\.app$/; +const NETLIFY_DEPLOY_PREVIEW_HOST = + /^deploy-preview-\d+--(?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.netlify\.app$/; export interface BuilderPreviewRelayState { v: 1; @@ -165,6 +169,48 @@ export function isTrustedBuilderRelayTargetOrigin(value: string): boolean { ) ); } + +/** + * Netlify's deploy-preview alias is convenient for people but mutable, so it + * must never be the signed relay destination. DEPLOY_URL is Netlify's + * immutable, deploy-specific URL. Use it only when it identifies the same + * site as the visible preview alias; otherwise preserve the visible origin so + * the existing fail-closed callback validation rejects the flow. + */ +export function resolveBuilderPreviewRelayTargetOrigin( + previewOrigin: string, +): string { + let previewUrl: URL; + try { + previewUrl = new URL(previewOrigin); + } catch { + return previewOrigin; + } + const previewMatch = NETLIFY_DEPLOY_PREVIEW_HOST.exec( + previewUrl.hostname.toLowerCase(), + ); + if (!previewMatch?.groups?.site) return previewOrigin; + + const deployUrlRaw = process.env.DEPLOY_URL?.trim(); + if (!deployUrlRaw) return previewOrigin; + try { + const deployUrl = new URL(deployUrlRaw); + const deployMatch = IMMUTABLE_NETLIFY_RELAY_HOST.exec( + deployUrl.hostname.toLowerCase(), + ); + if ( + deployUrl.protocol !== "https:" || + deployUrl.origin !== deployUrlRaw.replace(/\/+$/, "") || + !deployMatch?.groups?.site || + deployMatch.groups.site !== previewMatch.groups.site + ) { + return previewOrigin; + } + return deployUrl.origin; + } catch { + return previewOrigin; + } +} export function signBuilderPreviewRelayState(input: { ownerEmail: string; targetOrigin: string; @@ -1228,6 +1274,19 @@ export function resolveSafePreviewUrl( return getBuilderBrowserOriginForEvent(event); } +export function resolveBuilderPreviewRelayParentOrigin(options: { + openerOrigin?: string | null; + targetOrigin: string; +}): string { + if ( + options.openerOrigin && + isSafeBuilderRelayTargetOrigin(options.openerOrigin) + ) { + return new URL(options.openerOrigin).origin; + } + return options.targetOrigin; +} + export function resolveBuilderCallbackReturnUrl(options: { event: H3Event; openerOrigin?: string | null; diff --git a/packages/core/src/server/core-routes-plugin.ts b/packages/core/src/server/core-routes-plugin.ts index d2cc0a2d98..3662170983 100644 --- a/packages/core/src/server/core-routes-plugin.ts +++ b/packages/core/src/server/core-routes-plugin.ts @@ -127,6 +127,8 @@ import { resolveBuilderCallbackReturnUrl, getBuilderBrowserStatusForEvent, resolveBuilderBranchProjectId, + resolveBuilderPreviewRelayParentOrigin, + resolveBuilderPreviewRelayTargetOrigin, resolveSafePreviewUrl, runBuilderAgent, signBuilderCallbackState, @@ -2049,7 +2051,8 @@ export function createCoreRoutesPlugin( try { relay = signBuilderPreviewRelayState({ ownerEmail, - targetOrigin: previewOrigin, + targetOrigin: + resolveBuilderPreviewRelayTargetOrigin(previewOrigin), basePath: getAppBasePath(), }); } catch (err) { @@ -2422,6 +2425,13 @@ export function createCoreRoutesPlugin( ); } + const relayOpenerOrigin = + requestUrl.searchParams.get(BUILDER_OPENER_PARAM); + const relayParentOrigin = resolveBuilderPreviewRelayParentOrigin({ + openerOrigin: relayOpenerOrigin, + targetOrigin: relayPayload.targetOrigin, + }); + const privateKey = requestUrl.searchParams.get("p-key"); const publicKey = requestUrl.searchParams.get("api-key"); if (!privateKey || !publicKey) { @@ -2433,7 +2443,7 @@ export function createCoreRoutesPlugin( ); return createBuilderBrowserCallbackErrorPage( "Builder didn't return credentials. Restart the connect flow from settings.", - { parentOrigin: relayPayload.targetOrigin }, + { parentOrigin: relayParentOrigin }, ); } @@ -2489,7 +2499,7 @@ export function createCoreRoutesPlugin( "text/html; charset=utf-8", ); return createBuilderBrowserCallbackErrorPage(message, { - parentOrigin: relayPayload.targetOrigin, + parentOrigin: relayParentOrigin, }); } @@ -2499,8 +2509,8 @@ export function createCoreRoutesPlugin( "text/html; charset=utf-8", ); return createBuilderBrowserCallbackPage( - `${relayPayload.targetOrigin}${relayPayload.basePath || "/"}`, - { parentOrigin: relayPayload.targetOrigin }, + `${relayParentOrigin}${relayPayload.basePath || "/"}`, + { parentOrigin: relayParentOrigin }, ); } diff --git a/templates/content/changelog/2026-07-24-builder-connections-now-complete-securely-from-netlify-deplo.md b/templates/content/changelog/2026-07-24-builder-connections-now-complete-securely-from-netlify-deplo.md new file mode 100644 index 0000000000..90d377cdd4 --- /dev/null +++ b/templates/content/changelog/2026-07-24-builder-connections-now-complete-securely-from-netlify-deplo.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-24 +--- + +Builder connections now complete securely from Netlify deploy previews. From d75c43879d72fa190d661c653cea44a8ac830bff Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:59:28 -0400 Subject: [PATCH 05/51] fix(core): derive Netlify relay target at build time --- .../core/src/server/builder-browser.spec.ts | 12 ++--- packages/core/src/server/builder-browser.ts | 47 ++++++++++--------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/packages/core/src/server/builder-browser.spec.ts b/packages/core/src/server/builder-browser.spec.ts index 01692fa5c0..37a5707452 100644 --- a/packages/core/src/server/builder-browser.spec.ts +++ b/packages/core/src/server/builder-browser.spec.ts @@ -588,8 +588,8 @@ describe("Builder callback CSRF state", () => { }); it("uses the immutable Netlify deploy URL as the relay destination", () => { - process.env.DEPLOY_URL = - "https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app"; + process.env.AGENT_NATIVE_BUILD_ID = "6a62ed72f518f00008436fa3"; + process.env.SITE_NAME = "agent-native-content"; expect( resolveBuilderPreviewRelayTargetOrigin( @@ -601,8 +601,8 @@ describe("Builder callback CSRF state", () => { }); it("rejects an immutable Netlify deploy URL for a different site", () => { - process.env.DEPLOY_URL = - "https://6a62ed72f518f00008436fa3--different-site.netlify.app"; + process.env.AGENT_NATIVE_BUILD_ID = "6a62ed72f518f00008436fa3"; + process.env.SITE_NAME = "different-site"; const previewOrigin = "https://deploy-preview-2382--agent-native-content.netlify.app"; @@ -612,8 +612,8 @@ describe("Builder callback CSRF state", () => { }); it("leaves non-Netlify preview origins unchanged", () => { - process.env.DEPLOY_URL = - "https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app"; + process.env.AGENT_NATIVE_BUILD_ID = "6a62ed72f518f00008436fa3"; + process.env.SITE_NAME = "agent-native-content"; const previewOrigin = "https://preview-example.builderio.xyz"; expect(resolveBuilderPreviewRelayTargetOrigin(previewOrigin)).toBe( diff --git a/packages/core/src/server/builder-browser.ts b/packages/core/src/server/builder-browser.ts index 3776bb5125..51f3616887 100644 --- a/packages/core/src/server/builder-browser.ts +++ b/packages/core/src/server/builder-browser.ts @@ -15,6 +15,8 @@ import { } from "./better-auth-instance.js"; import { getAppBasePath, getOrigin } from "./google-oauth.js"; +declare const __AGENT_NATIVE_BUILD_ID__: string | undefined; + const DEFAULT_BUILDER_APP_HOST = "https://builder.io"; const DEFAULT_BUILDER_API_HOST = "https://api.builder.io"; const BUILDER_BROWSER_HOST = "agent-native-browser"; @@ -172,10 +174,11 @@ export function isTrustedBuilderRelayTargetOrigin(value: string): boolean { /** * Netlify's deploy-preview alias is convenient for people but mutable, so it - * must never be the signed relay destination. DEPLOY_URL is Netlify's - * immutable, deploy-specific URL. Use it only when it identifies the same - * site as the visible preview alias; otherwise preserve the visible origin so - * the existing fail-closed callback validation rejects the flow. + * must never be the signed relay destination. Vite captures Netlify's + * DEPLOY_ID into __AGENT_NATIVE_BUILD_ID__ during the build, while SITE_NAME + * remains available to Functions at runtime. Use that pair only when it + * identifies the same site as the visible preview alias; otherwise preserve + * the visible origin so callback validation fails closed. */ export function resolveBuilderPreviewRelayTargetOrigin( previewOrigin: string, @@ -191,25 +194,27 @@ export function resolveBuilderPreviewRelayTargetOrigin( ); if (!previewMatch?.groups?.site) return previewOrigin; - const deployUrlRaw = process.env.DEPLOY_URL?.trim(); - if (!deployUrlRaw) return previewOrigin; - try { - const deployUrl = new URL(deployUrlRaw); - const deployMatch = IMMUTABLE_NETLIFY_RELAY_HOST.exec( - deployUrl.hostname.toLowerCase(), - ); - if ( - deployUrl.protocol !== "https:" || - deployUrl.origin !== deployUrlRaw.replace(/\/+$/, "") || - !deployMatch?.groups?.site || - deployMatch.groups.site !== previewMatch.groups.site - ) { - return previewOrigin; - } - return deployUrl.origin; - } catch { + const buildId = ( + typeof __AGENT_NATIVE_BUILD_ID__ === "string" + ? __AGENT_NATIVE_BUILD_ID__ + : process.env.AGENT_NATIVE_BUILD_ID + ) + ?.trim() + .toLowerCase(); + const siteName = process.env.SITE_NAME?.trim().toLowerCase(); + if ( + !buildId || + !siteName || + !/^[a-f0-9]{24}$/.test(buildId) || + siteName !== previewMatch.groups.site + ) { return previewOrigin; } + + const immutableOrigin = `https://${buildId}--${siteName}.netlify.app`; + return IMMUTABLE_NETLIFY_RELAY_HOST.test(new URL(immutableOrigin).hostname) + ? immutableOrigin + : previewOrigin; } export function signBuilderPreviewRelayState(input: { ownerEmail: string; From da9e6eca58ba2e34d305737bad5b15f85f7f4cac Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:01:23 -0400 Subject: [PATCH 06/51] fix(core): bind Builder relay opener to preview site --- .../core/src/server/builder-browser.spec.ts | 12 ++++++++ packages/core/src/server/builder-browser.ts | 28 ++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/core/src/server/builder-browser.spec.ts b/packages/core/src/server/builder-browser.spec.ts index 37a5707452..c6d8223f78 100644 --- a/packages/core/src/server/builder-browser.spec.ts +++ b/packages/core/src/server/builder-browser.spec.ts @@ -643,6 +643,18 @@ describe("Builder callback CSRF state", () => { ).toBe(targetOrigin); }); + it("rejects an unsigned Netlify opener for a different site", () => { + const targetOrigin = + "https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app"; + expect( + resolveBuilderPreviewRelayParentOrigin({ + openerOrigin: + "https://deploy-preview-2382--attacker-site.netlify.app", + targetOrigin, + }), + ).toBe(targetOrigin); + }); + it("returns users to the preview opener after a gateway callback", () => { process.env.NODE_ENV = "production"; process.env.AGENT_NATIVE_WORKSPACE = "1"; diff --git a/packages/core/src/server/builder-browser.ts b/packages/core/src/server/builder-browser.ts index 51f3616887..3e0896e701 100644 --- a/packages/core/src/server/builder-browser.ts +++ b/packages/core/src/server/builder-browser.ts @@ -1283,13 +1283,33 @@ export function resolveBuilderPreviewRelayParentOrigin(options: { openerOrigin?: string | null; targetOrigin: string; }): string { + if (!options.openerOrigin) return options.targetOrigin; + let openerUrl: URL; + let targetUrl: URL; + try { + openerUrl = new URL(options.openerOrigin); + targetUrl = new URL(options.targetOrigin); + } catch { + return options.targetOrigin; + } if ( - options.openerOrigin && - isSafeBuilderRelayTargetOrigin(options.openerOrigin) + openerUrl.origin !== options.openerOrigin || + !isSafeBuilderRelayTargetOrigin(openerUrl.origin) ) { - return new URL(options.openerOrigin).origin; + return options.targetOrigin; } - return options.targetOrigin; + if (openerUrl.origin === targetUrl.origin) return openerUrl.origin; + + const openerMatch = NETLIFY_DEPLOY_PREVIEW_HOST.exec( + openerUrl.hostname.toLowerCase(), + ); + const targetMatch = IMMUTABLE_NETLIFY_RELAY_HOST.exec( + targetUrl.hostname.toLowerCase(), + ); + return openerMatch?.groups?.site && + openerMatch.groups.site === targetMatch?.groups?.site + ? openerUrl.origin + : options.targetOrigin; } export function resolveBuilderCallbackReturnUrl(options: { From ba98303d816bfce51d257f209b8f6bdd216f8d5d Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:24:11 -0400 Subject: [PATCH 07/51] fix(content): embed Builder relay deploy id --- packages/core/src/deploy/build.ts | 8 ++++++++ packages/core/src/server/builder-browser.ts | 16 ++++------------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/core/src/deploy/build.ts b/packages/core/src/deploy/build.ts index bdad068ff2..9701b7c843 100644 --- a/packages/core/src/deploy/build.ts +++ b/packages/core/src/deploy/build.ts @@ -3705,6 +3705,14 @@ export default bundle; "virtual:agents-bundle": agentsBundleModuleSource, }, replace: { + // Netlify exposes DEPLOY_ID only while building. Embed it into the Nitro + // function so preview OAuth relays can target this immutable deployment + // even though the value is unavailable in the function runtime. + "process.env.AGENT_NATIVE_BUILD_ID": JSON.stringify( + process.env.DEPLOY_ID?.trim() || + process.env.AGENT_NATIVE_BUILD_ID?.trim() || + "", + ), "process.env.AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID": JSON.stringify( process.env.GA_MEASUREMENT_ID?.trim() || "", ), diff --git a/packages/core/src/server/builder-browser.ts b/packages/core/src/server/builder-browser.ts index 3e0896e701..9d9726dd0a 100644 --- a/packages/core/src/server/builder-browser.ts +++ b/packages/core/src/server/builder-browser.ts @@ -15,8 +15,6 @@ import { } from "./better-auth-instance.js"; import { getAppBasePath, getOrigin } from "./google-oauth.js"; -declare const __AGENT_NATIVE_BUILD_ID__: string | undefined; - const DEFAULT_BUILDER_APP_HOST = "https://builder.io"; const DEFAULT_BUILDER_API_HOST = "https://api.builder.io"; const BUILDER_BROWSER_HOST = "agent-native-browser"; @@ -174,9 +172,9 @@ export function isTrustedBuilderRelayTargetOrigin(value: string): boolean { /** * Netlify's deploy-preview alias is convenient for people but mutable, so it - * must never be the signed relay destination. Vite captures Netlify's - * DEPLOY_ID into __AGENT_NATIVE_BUILD_ID__ during the build, while SITE_NAME - * remains available to Functions at runtime. Use that pair only when it + * must never be the signed relay destination. The deploy builder embeds + * Netlify's DEPLOY_ID into the Nitro server bundle, while SITE_NAME remains + * available to Functions at runtime. Use that pair only when it * identifies the same site as the visible preview alias; otherwise preserve * the visible origin so callback validation fails closed. */ @@ -194,13 +192,7 @@ export function resolveBuilderPreviewRelayTargetOrigin( ); if (!previewMatch?.groups?.site) return previewOrigin; - const buildId = ( - typeof __AGENT_NATIVE_BUILD_ID__ === "string" - ? __AGENT_NATIVE_BUILD_ID__ - : process.env.AGENT_NATIVE_BUILD_ID - ) - ?.trim() - .toLowerCase(); + const buildId = process.env.AGENT_NATIVE_BUILD_ID?.trim().toLowerCase(); const siteName = process.env.SITE_NAME?.trim().toLowerCase(); if ( !buildId || From 93ca4bf64cf1c00a4e13185ab5240bda5aa5aa33 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:18:45 -0400 Subject: [PATCH 08/51] fix(content): bound Builder review body loading --- .../actions/_database-source-utils.test.ts | 56 ++++++ .../content/actions/_database-source-utils.ts | 180 +++++++++++++++++- .../builder-source-review-gates.db.test.ts | 87 ++++++++- .../actions/prepare-builder-source-review.ts | 7 +- .../actions/preview-builder-source-review.ts | 4 +- ...s-now-open-reliably-on-large-collection.md | 6 + 6 files changed, 331 insertions(+), 9 deletions(-) create mode 100644 templates/content/changelog/2026-07-24-builder-update-reviews-now-open-reliably-on-large-collection.md diff --git a/templates/content/actions/_database-source-utils.test.ts b/templates/content/actions/_database-source-utils.test.ts index 44cf639ce5..00eb1f9a52 100644 --- a/templates/content/actions/_database-source-utils.test.ts +++ b/templates/content/actions/_database-source-utils.test.ts @@ -24,6 +24,7 @@ import { builderBodyHydrationPriorityForRequest, builderBodyHydrationAttemptIsTerminal, builderBodyNeedsSourceComponentWrite, + builderReviewBodyCandidateDocumentIds, builderBodyHydrationVersion, builderBodyUnavailableVersion, builderBodyHydrationNeedsLiveBaseline, @@ -945,6 +946,61 @@ describe("database source helpers", () => { expect(pending).toHaveLength(0); }); + it("indexes only Builder documents that need a heavy body review", () => { + const imported = { + sourceRowId: "entry-1", + sourceQualifiedId: "builder-cms://safe-model/entry-1", + provenance: "Builder CMS read adapter", + bodyHydrationStatus: "hydrated", + currentHash: "hash-1", + currentContent: "Same readable body", + }; + + expect( + builderReviewBodyCandidateDocumentIds([ + { + ...imported, + documentId: "unchanged", + localContent: "Same readable body\n", + }, + { + ...imported, + documentId: "changed", + localContent: "Locally edited body", + }, + { + ...imported, + documentId: "media-reconversion", + currentContent: "![Image](https://example.com/image.png)", + localContent: "![Image](https://example.com/image.png)", + }, + { + ...imported, + documentId: "not-hydrated", + bodyHydrationStatus: "pending", + localContent: "Locally edited body", + }, + { + ...imported, + documentId: "empty-no-baseline", + currentHash: null, + currentContent: null, + localContent: "", + }, + { + documentId: "fixture", + sourceRowId: "builder-fixture", + sourceQualifiedId: "builder-cms://safe-model/fixture", + provenance: BUILDER_CMS_FIXTURE_ROW_PROVENANCE, + bodyHydrationStatus: "pending", + currentHash: null, + currentContent: null, + localContent: "Local draft body", + }, + ]), + ).toEqual(["changed", "media-reconversion", "fixture"]); + }); + it("detects local Builder body edits as outbound pending changes", () => { const [changeSet] = buildBuilderLocalOutboundChangeSets({ source: { sourceType: "builder-cms" }, diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index e2eaf8026a..68fb68cd51 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -843,6 +843,15 @@ function normalizeBuilderBodyBaselineContent(value: string | null | undefined) { return (value ?? "").replace(/\r\n/g, "\n").trim(); } +function builderBodyUsesCurrentMediaConverter( + content: string | null | undefined, +) { + return ( + /!\[[^\]]*\]\(\s*https?:\/\//i.test(content ?? "") || + /<(?:img|video)\b/i.test(content ?? "") + ); +} + const BUILDER_BODY_HYDRATION_BACKGROUND_PRIORITY = 10; const BUILDER_BODY_HYDRATION_OPEN_PRIORITY = 0; const BUILDER_BODY_HYDRATION_BATCH_LIMIT = 25; @@ -2497,8 +2506,7 @@ export async function builderBodyChangeForLocalContent(args: { // legacy Text block containing a Markdown image into a real Builder Image // (or emit a native Video) without changing the document text at all. const usesCurrentMediaConverter = - /!\[[^\]]*\]\(\s*https?:\/\//i.test(localContent) || - /<(?:img|video)\b/i.test(localContent); + builderBodyUsesCurrentMediaConverter(localContent); const normalizedLocalContent = normalizeBuilderBodyBaselineContent(localContent); if ( @@ -3206,6 +3214,174 @@ export async function getContentDatabaseSourceSnapshotForWrite( : null; } +export type BuilderReviewBodyCandidateRow = { + documentId: string; + sourceRowId: string; + sourceQualifiedId: string; + provenance: string | null; + bodyHydrationStatus: string; + currentHash: string | null; + currentContent: string | null; + localContent: string | null; +}; + +export function builderReviewBodyCandidateDocumentIds( + rows: BuilderReviewBodyCandidateRow[], +) { + return rows.flatMap((row) => { + const identity = builderCmsSourceRowIdentityState({ row }); + const localContent = row.localContent ?? ""; + if (identity.isSyntheticFixture) { + return localContent.trim() ? [row.documentId] : []; + } + if (row.bodyHydrationStatus !== "hydrated") return []; + if (!row.currentHash && !row.currentContent && !localContent.trim()) { + return []; + } + if (builderBodyUsesCurrentMediaConverter(localContent)) { + return [row.documentId]; + } + const normalizedLocalContent = + normalizeBuilderBodyBaselineContent(localContent); + if ( + normalizedLocalContent && + normalizedLocalContent === + normalizeBuilderBodyBaselineContent(row.currentContent) + ) { + return []; + } + return [row.documentId]; + }); +} + +type BuilderReviewSourceValueTextKey = + | typeof BUILDER_CMS_BODY_BLOCKS_HASH_KEY + | typeof BUILDER_CMS_BODY_CONTENT_KEY; + +export function builderReviewSourceValueTextProjection( + key: BuilderReviewSourceValueTextKey, +) { + const sourceValuesJson = schema.contentDatabaseSourceRows.sourceValuesJson; + return getDialect() === "postgres" + ? sql`COALESCE(${sourceValuesJson}::jsonb ->> ${key}, '')` + : sql`COALESCE(json_extract(${sourceValuesJson}, ${`$."${key}"`}), '')`; +} + +async function findBuilderReviewBodyCandidateDocumentIds(args: { + database: ContentDatabaseRow | ContentDatabase; + source: ContentDatabaseSourceRowDb; +}) { + const sourceRows = schema.contentDatabaseSourceRows; + const items = schema.contentDatabaseItems; + const documents = schema.documents; + const rows = await getDb() + .select({ + documentId: sourceRows.documentId, + sourceRowId: sourceRows.sourceRowId, + sourceQualifiedId: sourceRows.sourceQualifiedId, + provenance: sourceRows.provenance, + bodyHydrationStatus: items.bodyHydrationStatus, + currentHash: builderReviewSourceValueTextProjection( + BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + ), + currentContent: builderReviewSourceValueTextProjection( + BUILDER_CMS_BODY_CONTENT_KEY, + ), + localContent: documents.content, + }) + .from(sourceRows) + .innerJoin( + items, + and( + eq(items.databaseId, args.database.id), + eq(items.documentId, sourceRows.documentId), + eq(items.ownerEmail, args.source.ownerEmail), + ), + ) + .innerJoin( + documents, + and( + eq(documents.id, sourceRows.documentId), + eq(documents.ownerEmail, args.source.ownerEmail), + ), + ) + .where(eq(sourceRows.sourceId, args.source.id)); + return builderReviewBodyCandidateDocumentIds(rows); +} + +/** + * Load a complete Builder review snapshot without transferring every heavy + * Builder body baseline. A light pass identifies field/stored changes, while a + * narrow body index compares the editable document with the readable baseline. + * Only candidate documents then load lossless body data and sidecars. + */ +export async function getContentDatabaseSourceSnapshotForReview( + database: ContentDatabaseRow | ContentDatabase, + sourceId?: string | null, + documentIds?: string[], +): Promise { + if (documentIds?.length) { + return getContentDatabaseSourceSnapshotForWrite( + database, + sourceId, + documentIds, + ); + } + + const db = getDb(); + const [source] = sourceId + ? await db + .select() + .from(schema.contentDatabaseSources) + .where( + and( + eq(schema.contentDatabaseSources.id, sourceId), + eq(schema.contentDatabaseSources.databaseId, database.id), + ), + ) + : await db + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.databaseId, database.id)) + .orderBy( + asc(schema.contentDatabaseSources.createdAt), + asc(schema.contentDatabaseSources.id), + ); + if (!source) return null; + + const lightweight = await loadSourceSnapshot(source, database, { + includeHeavyBuilderBodyValues: false, + }); + const reviewableChanges = lightweight.changeSets.filter( + (changeSet) => + changeSet.direction === "outbound" && + (changeSet.state === "pending_push" || + changeSet.state === "staged_revision" || + changeSet.state === "approved"), + ); + if (reviewableChanges.some((changeSet) => !changeSet.documentId)) { + return loadSourceSnapshot(source, database, { + includeHeavyBuilderBodyValues: true, + }); + } + const candidateDocumentIds = new Set( + reviewableChanges.flatMap((changeSet) => + changeSet.documentId ? [changeSet.documentId] : [], + ), + ); + for (const documentId of await findBuilderReviewBodyCandidateDocumentIds({ + database, + source, + })) { + candidateDocumentIds.add(documentId); + } + if (candidateDocumentIds.size === 0) return lightweight; + return loadSourceSnapshot(source, database, { + includeHeavyBuilderBodyValues: true, + documentIds: [...candidateDocumentIds], + }); +} + /** * Load every source attached to a database (oldest first → `[0]` is the * primary). Federation joins read this; single-source callers keep using diff --git a/templates/content/actions/builder-source-review-gates.db.test.ts b/templates/content/actions/builder-source-review-gates.db.test.ts index 7d94357bc1..8dcf975ded 100644 --- a/templates/content/actions/builder-source-review-gates.db.test.ts +++ b/templates/content/actions/builder-source-review-gates.db.test.ts @@ -7,6 +7,10 @@ import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { BUILDER_CMS_SAFE_WRITE_MODEL } from "../shared/api"; +import { + BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + BUILDER_CMS_BODY_CONTENT_KEY, +} from "./_builder-cms-source-adapter"; const TEST_DB_PATH = join( tmpdir(), @@ -16,9 +20,11 @@ const TEST_DB_PATH = join( const OWNER = "owner@example.com"; const heavySnapshotReads = vi.hoisted(() => ({ + review: 0, target: 0, allSources: 0, omitTargetRows: false, + reviewDocumentScopes: [] as Array, documentScopes: [] as Array, })); vi.mock("./_database-source-utils.js", async (importOriginal) => { @@ -26,6 +32,22 @@ vi.mock("./_database-source-utils.js", async (importOriginal) => { await importOriginal(); return { ...original, + getContentDatabaseSourceSnapshotForReview: async ( + ...args: Parameters< + typeof original.getContentDatabaseSourceSnapshotForReview + > + ) => { + heavySnapshotReads.review += 1; + heavySnapshotReads.reviewDocumentScopes.push( + args[2] ? [...args[2]] : null, + ); + const snapshot = await original.getContentDatabaseSourceSnapshotForReview( + ...args, + ); + return snapshot && heavySnapshotReads.omitTargetRows + ? { ...snapshot, rows: [] } + : snapshot; + }, getContentDatabaseSourceSnapshotForWrite: async ( ...args: Parameters< typeof original.getContentDatabaseSourceSnapshotForWrite @@ -55,6 +77,8 @@ let prepareReview: typeof import("./prepare-builder-source-review.js").default; let previewReview: typeof import("./preview-builder-source-review.js").default; let prepareExecution: typeof import("./prepare-builder-source-execution.js").default; let validateExecution: typeof import("./validate-builder-source-execution.js").default; +let builderReviewBodyCandidateDocumentIds: typeof import("./_database-source-utils.js").builderReviewBodyCandidateDocumentIds; +let builderReviewSourceValueTextProjection: typeof import("./_database-source-utils.js").builderReviewSourceValueTextProjection; beforeAll(async () => { process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; @@ -69,6 +93,10 @@ beforeAll(async () => { .default; validateExecution = (await import("./validate-builder-source-execution.js")) .default; + ({ + builderReviewBodyCandidateDocumentIds, + builderReviewSourceValueTextProjection, + } = await import("./_database-source-utils.js")); }, 60000); afterAll(() => { @@ -224,6 +252,51 @@ async function seedBuilderSource(args: { } describe("Builder source review execution gates", () => { + it("reads dotted Builder body keys on SQLite and keeps local deletions reviewable", async () => { + const seeded = await seedBuilderSource({ + sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, + }); + await getDb() + .update(schema.contentDatabaseSourceRows) + .set({ + sourceValuesJson: JSON.stringify({ + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "stored-hash", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Stored readable body", + }), + }) + .where(eq(schema.contentDatabaseSourceRows.sourceId, seeded.sourceId)); + + const [projected] = await getDb() + .select({ + documentId: schema.contentDatabaseSourceRows.documentId, + sourceRowId: schema.contentDatabaseSourceRows.sourceRowId, + sourceQualifiedId: schema.contentDatabaseSourceRows.sourceQualifiedId, + provenance: schema.contentDatabaseSourceRows.provenance, + currentHash: builderReviewSourceValueTextProjection( + BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + ), + currentContent: builderReviewSourceValueTextProjection( + BUILDER_CMS_BODY_CONTENT_KEY, + ), + }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, seeded.sourceId)); + + expect(projected).toMatchObject({ + currentHash: "stored-hash", + currentContent: "Stored readable body", + }); + expect( + builderReviewBodyCandidateDocumentIds([ + { + ...projected, + bodyHydrationStatus: "hydrated", + localContent: "", + }, + ]), + ).toEqual([seeded.rowDocumentId]); + }); + it("restores authoritative Builder targets in selected read-only previews", async () => { const seeded = await seedBuilderSource({ sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, @@ -255,6 +328,7 @@ describe("Builder source review execution gates", () => { sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, }); heavySnapshotReads.documentScopes = []; + heavySnapshotReads.reviewDocumentScopes = []; heavySnapshotReads.omitTargetRows = true; try { const response = await asOwner(() => @@ -287,8 +361,10 @@ describe("Builder source review execution gates", () => { documentId: expect.stringMatching(/^doc_row_/), }); expect(JSON.parse(execution.payloadJson).request.method).toBe("PATCH"); - expect(heavySnapshotReads.documentScopes).toEqual([ + expect(heavySnapshotReads.reviewDocumentScopes).toEqual([ [seeded.rowDocumentId], + ]); + expect(heavySnapshotReads.documentScopes).toEqual([ [seeded.rowDocumentId], ]); } finally { @@ -473,7 +549,7 @@ describe("Builder source review execution gates", () => { expect(response.review.rows[0]?.bodyChange).toEqual(staleBody); }); - it("builds the persisted gate from the exact approved selection with two heavy snapshots", async () => { + it("builds the persisted gate from the exact approved selection with bounded review snapshots", async () => { const bodyChange = { summary: "Body changed", currentExcerpt: "Old", @@ -494,6 +570,7 @@ describe("Builder source review execution gates", () => { sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, bodyChange, }); + heavySnapshotReads.review = 0; heavySnapshotReads.target = 0; heavySnapshotReads.allSources = 0; @@ -523,7 +600,11 @@ describe("Builder source review execution gates", () => { const persistedBody = JSON.parse(persistedChangeSet.bodyChangeJson); const executionPayload = JSON.parse(execution.payloadJson); - expect(heavySnapshotReads).toMatchObject({ target: 2, allSources: 1 }); + expect(heavySnapshotReads).toMatchObject({ + review: 1, + target: 1, + allSources: 1, + }); expect(persistedChangeSet.state).toBe("approved"); expect(response.review.rows[0]?.fieldChanges).toEqual(persistedFields); expect(response.review.rows[0]?.bodyChange).toEqual(persistedBody); diff --git a/templates/content/actions/prepare-builder-source-review.ts b/templates/content/actions/prepare-builder-source-review.ts index aa6d891a1b..03a34134ed 100644 --- a/templates/content/actions/prepare-builder-source-review.ts +++ b/templates/content/actions/prepare-builder-source-review.ts @@ -29,6 +29,7 @@ import { createBuilderSourceTiming } from "./_builder-source-timings.js"; import { canRefreshLocallyBlockedBuilderReview, findOpenSourceChangeSet, + getContentDatabaseSourceSnapshotForReview, getContentDatabaseSourceSnapshotForWrite, resolveDatabaseForSourceMutation, serializeSourceRowRecord, @@ -667,7 +668,7 @@ export default defineAction({ const database = await resolveDatabaseForSourceMutation(args); if (!database) throw new Error("Database not found."); await assertAccess("document", database.documentId, "editor"); - const snapshot = await getContentDatabaseSourceSnapshotForWrite( + const snapshot = await getContentDatabaseSourceSnapshotForReview( database, args.sourceId, args.documentIds, @@ -773,7 +774,9 @@ export default defineAction({ reviewedSnapshot: await getContentDatabaseSourceSnapshotForWrite( database, args.sourceId, - args.documentIds, + reviewableChanges.every((changeSet) => changeSet.documentId) + ? reviewableChanges.map((changeSet) => changeSet.documentId!) + : args.documentIds, ), response: await getContentDatabaseResponse(database.id), }), diff --git a/templates/content/actions/preview-builder-source-review.ts b/templates/content/actions/preview-builder-source-review.ts index 4e8a718775..6cd629802f 100644 --- a/templates/content/actions/preview-builder-source-review.ts +++ b/templates/content/actions/preview-builder-source-review.ts @@ -7,7 +7,7 @@ import type { PreviewBuilderSourceReviewResponse, } from "../shared/api.js"; import { - getContentDatabaseSourceSnapshotForWrite, + getContentDatabaseSourceSnapshotForReview, resolveDatabaseForSourceMutation, } from "./_database-source-utils.js"; import { @@ -45,7 +45,7 @@ export default defineAction({ throw new Error("Select at least one Builder row before reviewing it."); } - const source = await getContentDatabaseSourceSnapshotForWrite( + const source = await getContentDatabaseSourceSnapshotForReview( database, args.sourceId, args.scope === "selected" ? args.documentIds : undefined, diff --git a/templates/content/changelog/2026-07-24-builder-update-reviews-now-open-reliably-on-large-collection.md b/templates/content/changelog/2026-07-24-builder-update-reviews-now-open-reliably-on-large-collection.md new file mode 100644 index 0000000000..89356d0600 --- /dev/null +++ b/templates/content/changelog/2026-07-24-builder-update-reviews-now-open-reliably-on-large-collection.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-24 +--- + +Builder update reviews now open reliably on large collections by loading full article details only for pending changes. From 924cabae3d230658fb1097dbf54c71a39afc3e6c Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:27:52 -0400 Subject: [PATCH 09/51] fix(content): prioritize new Builder drafts in review --- .../content-database-source-actions.test.ts | 30 +++++++++++++++++++ .../actions/prepare-builder-source-review.ts | 22 +++++++++++--- .../actions/preview-builder-source-review.ts | 3 +- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/templates/content/actions/content-database-source-actions.test.ts b/templates/content/actions/content-database-source-actions.test.ts index b25876d6c6..3539a5ecbf 100644 --- a/templates/content/actions/content-database-source-actions.test.ts +++ b/templates/content/actions/content-database-source-actions.test.ts @@ -25,6 +25,7 @@ import prepareExecution from "./prepare-builder-source-execution"; import prepareReview, { BUILDER_SOURCE_REVIEW_PREPARE_LIMIT, buildBuilderSourceReviewPayload, + reviewPreparePriority, } from "./prepare-builder-source-review"; import previewReview from "./preview-builder-source-review"; import refreshSource from "./refresh-content-database-source"; @@ -60,6 +61,35 @@ describe("content database source actions", () => { }); }); + it("surfaces new Builder drafts before in-place updates in the same review state", () => { + const source = { + metadata: { writeMode: "publish_updates", pushMode: "publish" }, + rows: [ + { + documentId: "existing-document", + databaseItemId: "existing-item", + sourceRowId: "existing-entry", + sourceQualifiedId: "builder-cms://safe-model/existing-entry", + provenance: "Builder CMS read adapter", + }, + ], + } as ContentDatabaseSource; + const create = { + id: "create", + documentId: "new-document", + state: "pending_push", + } as ContentDatabaseSource["changeSets"][number]; + const update = { + id: "update", + documentId: "existing-document", + state: "pending_push", + } as ContentDatabaseSource["changeSets"][number]; + + expect(reviewPreparePriority(create, source)).toBeLessThan( + reviewPreparePriority(update, source), + ); + }); + it("accepts Builder source batch execution args", () => { expect( executeBatch.schema.parse({ diff --git a/templates/content/actions/prepare-builder-source-review.ts b/templates/content/actions/prepare-builder-source-review.ts index 03a34134ed..2c005239b2 100644 --- a/templates/content/actions/prepare-builder-source-review.ts +++ b/templates/content/actions/prepare-builder-source-review.ts @@ -126,10 +126,20 @@ function maxRisk( export function reviewPreparePriority( changeSet: ContentDatabaseSourceChangeSet, + source?: ContentDatabaseSource, ) { - if (changeSet.state === "pending_push") return 0; - if (changeSet.state === "staged_revision") return 1; - return 2; + const statePriority = + changeSet.state === "pending_push" + ? 0 + : changeSet.state === "staged_revision" + ? 2 + : 4; + const effectPriority = + source && + resolveBuilderCmsWriteEffect({ source, changeSet }) === "create_draft" + ? 0 + : 1; + return statePriority + effectPriority; } function parsePayload(value: string) { @@ -717,7 +727,11 @@ export default defineAction({ throw new Error("No pending local Builder changes to review."); } const reviewableChanges = [...allReviewableChanges] - .sort((a, b) => reviewPreparePriority(a) - reviewPreparePriority(b)) + .sort( + (a, b) => + reviewPreparePriority(a, snapshot) - + reviewPreparePriority(b, snapshot), + ) .slice(0, BUILDER_SOURCE_REVIEW_PREPARE_LIMIT); const authoritativeSnapshot = await withAuthoritativeBuilderTargetRows({ source: snapshot, diff --git a/templates/content/actions/preview-builder-source-review.ts b/templates/content/actions/preview-builder-source-review.ts index 6cd629802f..bc620deea7 100644 --- a/templates/content/actions/preview-builder-source-review.ts +++ b/templates/content/actions/preview-builder-source-review.ts @@ -72,7 +72,8 @@ export default defineAction({ const changeSets = [...allReviewableChanges] .sort( (left, right) => - reviewPreparePriority(left) - reviewPreparePriority(right), + reviewPreparePriority(left, source) - + reviewPreparePriority(right, source), ) .slice(0, BUILDER_SOURCE_REVIEW_PREPARE_LIMIT); const authoritativeSource = await withAuthoritativeBuilderTargetRows({ From de93115992bf49558d4361280ee8978ce29c636c Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:53:44 -0400 Subject: [PATCH 10/51] fix(content): persist first collaborative edits --- .changeset/bright-pages-persist.md | 5 ++ .../useCollabReconcile.concurrent.spec.ts | 77 +++++++++++++++++++ .../toolkit/src/editor/useCollabReconcile.ts | 21 ++++- ...rative-pages-now-save-their-first-edits.md | 6 ++ 4 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 .changeset/bright-pages-persist.md create mode 100644 templates/content/changelog/2026-07-24-new-collaborative-pages-now-save-their-first-edits.md diff --git a/.changeset/bright-pages-persist.md b/.changeset/bright-pages-persist.md new file mode 100644 index 0000000000..c9106c2e1b --- /dev/null +++ b/.changeset/bright-pages-persist.md @@ -0,0 +1,5 @@ +--- +"@agent-native/toolkit": patch +--- + +Allow the first local edit in a newly synced empty collaborative document to reach the host application's canonical save path. diff --git a/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts b/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts index cdcef92dca..c9ccf350e7 100644 --- a/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts +++ b/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts @@ -4,6 +4,7 @@ import { useEditor, type Editor } from "@tiptap/react"; import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import * as Y from "yjs"; import { createRichMarkdownExtensions } from "./RichMarkdownEditor.js"; import { useCollabReconcile, getEditorMarkdown } from "./useCollabReconcile.js"; @@ -179,6 +180,52 @@ function render( } describe("useCollabReconcile — concurrent edit / lost-update guards", () => { + it("persists the first local edit after a synced empty collaborative document", async () => { + const captured: Captured = { + editor: null, + emitted: [], + setContentCalls: 0, + }; + + function EmptyDocumentHarness() { + const guardsRef = React.useRef | null>(null); + const ydoc = React.useMemo(() => new Y.Doc(), []); + const editor = useEditor({ + extensions: createRichMarkdownExtensions({ dialect: "gfm", ydoc }), + onUpdate: ({ editor, transaction }) => { + const guards = guardsRef.current; + if (!guards || guards.shouldIgnoreUpdate(transaction)) return; + const markdown = getEditorMarkdown(editor); + if (!guards.registerEmitted(markdown)) return; + captured.emitted.push(markdown); + }, + }); + captured.editor = editor; + guardsRef.current = useCollabReconcile({ + editor, + ydoc, + collabSynced: true, + value: "", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + getMarkdown: getEditorMarkdown, + }); + return React.createElement("div", null); + } + + act(() => root.render(React.createElement(EmptyDocumentHarness))); + await flush(); + + act(() => { + captured.editor?.commands.insertContent("First persisted edit"); + }); + await flush(); + + expect(captured.emitted).toContain("First persisted edit"); + }); + it("does not seed until initial collab sync has completed", async () => { const { captured, Harness } = makeCollabSeedHarness(); @@ -330,6 +377,36 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { expect(results).toEqual([true]); }); + it("allows a local edit that arrives as a synced empty document finishes mounting", async () => { + const results: boolean[] = []; + + function Probe() { + const editor = useEditor({ + extensions: createRichMarkdownExtensions({ dialect: "gfm" }), + content: "", + }); + const fakeYdoc = { clientID: 1, getXmlFragment: () => ({ length: 0 }) }; + const guards = useCollabReconcile({ + editor, + ydoc: fakeYdoc as never, + collabSynced: true, + value: "", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + }); + // Runs during render, before the passive empty-seed completion effect. + if (editor && results.length === 0) { + results.push(guards.shouldIgnoreUpdate(editor.state.tr)); + } + return React.createElement("div", null); + } + + act(() => root.render(React.createElement(Probe))); + await flush(); + + expect(results).toEqual([false]); + }); + it("refuses to persist an empty doc in collab mode (registerEmitted guard)", async () => { // Directly exercise the guard contract: in collab mode an empty markdown // string must not be registered/persisted (would clobber stored content diff --git a/packages/toolkit/src/editor/useCollabReconcile.ts b/packages/toolkit/src/editor/useCollabReconcile.ts index 4d38864faa..efad5274e7 100644 --- a/packages/toolkit/src/editor/useCollabReconcile.ts +++ b/packages/toolkit/src/editor/useCollabReconcile.ts @@ -300,8 +300,16 @@ export function useCollabReconcile({ if (seededRef.current) return; if (!collabSynced) return; if (!isLeadClient) return; - if (!value.trim()) return; const fragment = ydoc.getXmlFragment("default"); + // A brand-new document has no SQL markdown to seed. Once its initial Yjs + // state has loaded, that empty fragment is nevertheless fully initialized: + // local edits must be allowed through the autosave guard. Leaving + // `seededRef` false here trapped every first edit in "pre-seed" forever — + // the text survived in Yjs, but the canonical SQL body stayed empty. + if (!value.trim()) { + seededRef.current = true; + return; + } const currentMarkdown = getMarkdown(editor); // Seed only when the shared doc is genuinely empty — either the fragment has // no nodes yet, or it holds no semantic markdown (an empty paragraph, or an @@ -558,7 +566,16 @@ export function useCollabReconcile({ // app's local mirror or autosave path (Content serializes an empty paragraph // as ``, which is non-empty text and bypasses the generic // registerEmitted empty-string guard). - if (collab && !seededRef.current) return true; + if (collab && !seededRef.current) { + // Passive effects normally mark a synced empty document initialized + // before a person can type. Keep the event boundary correct too: if a + // genuine local edit wins that tiny race, it is itself proof that the + // synced empty document is ready. Remote Yjs transactions stay ignored. + const firstSyncedEmptyLocalEdit = + collabSynced && !value.trim() && !isChangeOrigin(transaction); + if (!firstSyncedEmptyLocalEdit) return true; + seededRef.current = true; + } if (transaction.getMeta(RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION)) { return true; } diff --git a/templates/content/changelog/2026-07-24-new-collaborative-pages-now-save-their-first-edits.md b/templates/content/changelog/2026-07-24-new-collaborative-pages-now-save-their-first-edits.md new file mode 100644 index 0000000000..c190d52624 --- /dev/null +++ b/templates/content/changelog/2026-07-24-new-collaborative-pages-now-save-their-first-edits.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-24 +--- + +New collaborative pages now save their first text and media edits before you navigate away. From 4ed5c4d625391dacee03bc7397b2779b2564ea19 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:00:30 -0400 Subject: [PATCH 11/51] fix(content): scope Builder execution snapshots --- .../builder-source-review-gates.db.test.ts | 20 ++++++++++ .../execute-builder-source-execution.ts | 38 +++++++++++++++++-- ...n-now-scopes-execution-to-the-selected-.md | 6 +++ 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 templates/content/changelog/2026-07-24-builder-draft-creation-now-scopes-execution-to-the-selected-.md diff --git a/templates/content/actions/builder-source-review-gates.db.test.ts b/templates/content/actions/builder-source-review-gates.db.test.ts index 8dcf975ded..7c3770ff88 100644 --- a/templates/content/actions/builder-source-review-gates.db.test.ts +++ b/templates/content/actions/builder-source-review-gates.db.test.ts @@ -77,6 +77,7 @@ let prepareReview: typeof import("./prepare-builder-source-review.js").default; let previewReview: typeof import("./preview-builder-source-review.js").default; let prepareExecution: typeof import("./prepare-builder-source-execution.js").default; let validateExecution: typeof import("./validate-builder-source-execution.js").default; +let realExecutionDeps: typeof import("./execute-builder-source-execution.js").realExecutionDeps; let builderReviewBodyCandidateDocumentIds: typeof import("./_database-source-utils.js").builderReviewBodyCandidateDocumentIds; let builderReviewSourceValueTextProjection: typeof import("./_database-source-utils.js").builderReviewSourceValueTextProjection; @@ -93,6 +94,8 @@ beforeAll(async () => { .default; validateExecution = (await import("./validate-builder-source-execution.js")) .default; + realExecutionDeps = (await import("./execute-builder-source-execution.js")) + .realExecutionDeps; ({ builderReviewBodyCandidateDocumentIds, builderReviewSourceValueTextProjection, @@ -372,6 +375,23 @@ describe("Builder source review execution gates", () => { } }); + it("scopes live execution snapshots to the prepared change-set document", async () => { + const seeded = await seedBuilderSource({ + sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, + }); + heavySnapshotReads.documentScopes = []; + + const deps = realExecutionDeps(seeded.sourceId, seeded.changeSetId); + const database = await deps.resolveDatabase({ + documentId: seeded.databaseDocumentId, + }); + expect(database).not.toBeNull(); + + await deps.getSourceSnapshot(database!); + + expect(heavySnapshotReads.documentScopes).toEqual([[seeded.rowDocumentId]]); + }); + it("refreshes a previously approved body only from a provably unsent blocked dry run", async () => { const staleBody = { summary: "Old blocked body", diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index d3b454044a..883982d343 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -637,6 +637,7 @@ async function reconcileBuilderCmsWrite(args: { export function realExecutionDeps( sourceId?: string, + changeSetId?: string, ): ExecuteBuilderSourceExecutionDeps { return { now: () => new Date().toISOString(), @@ -644,8 +645,39 @@ export function realExecutionDeps( assertEditor: async (database) => { await assertAccess("document", database.documentId, "editor"); }, - getSourceSnapshot: (database) => - getContentDatabaseSourceSnapshotForWrite(database, sourceId), + getSourceSnapshot: async (database) => { + // Execution always targets one prepared change set. Loading every + // Builder-backed document (and every heavy body baseline) here can spend + // the hosted request budget before the provider write is dispatched. + // Resolve the durable prepared target first, then build the authoritative + // write snapshot for that document only. The unscoped fallback preserves + // compatibility for legacy/non-persisted callers. + const [target] = changeSetId + ? await getDb() + .select({ + documentId: schema.contentDatabaseSourceChangeSets.documentId, + sourceId: schema.contentDatabaseSourceChangeSets.sourceId, + }) + .from(schema.contentDatabaseSourceChangeSets) + .where( + sourceId + ? and( + eq(schema.contentDatabaseSourceChangeSets.id, changeSetId), + eq( + schema.contentDatabaseSourceChangeSets.sourceId, + sourceId, + ), + ) + : eq(schema.contentDatabaseSourceChangeSets.id, changeSetId), + ) + .limit(1) + : []; + return getContentDatabaseSourceSnapshotForWrite( + database, + sourceId ?? target?.sourceId, + target?.documentId ? [target.documentId] : undefined, + ); + }, getExecution: async (args) => { const [claim] = await getDb() .select({ @@ -1297,7 +1329,7 @@ export default defineAction({ ): Promise => { return executeBuilderSourceExecutionWithDeps( args, - realExecutionDeps(args.sourceId), + realExecutionDeps(args.sourceId, args.changeSetId), ); }, }); diff --git a/templates/content/changelog/2026-07-24-builder-draft-creation-now-scopes-execution-to-the-selected-.md b/templates/content/changelog/2026-07-24-builder-draft-creation-now-scopes-execution-to-the-selected-.md new file mode 100644 index 0000000000..ffcb5e5151 --- /dev/null +++ b/templates/content/changelog/2026-07-24-builder-draft-creation-now-scopes-execution-to-the-selected-.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-24 +--- + +Builder draft creation now scopes execution to the selected article, avoiding hosted timeouts on large synced collections. From 4fa62fd1dd307d425cbae271414a44cb0289c6c9 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:30:59 -0400 Subject: [PATCH 12/51] fix(content): preserve Builder field bindings on refresh --- .../actions/_database-source-utils.test.ts | 50 +++++++ .../content/actions/_database-source-utils.ts | 123 +++++++++++++----- .../resync-content-database-source.db.test.ts | 64 +++++++++ ...h-now-preserves-the-provider-bound-fiel.md | 6 + 4 files changed, 212 insertions(+), 31 deletions(-) create mode 100644 templates/content/changelog/2026-07-24-builder-source-refresh-now-preserves-the-provider-bound-fiel.md diff --git a/templates/content/actions/_database-source-utils.test.ts b/templates/content/actions/_database-source-utils.test.ts index 00eb1f9a52..61857a0d0f 100644 --- a/templates/content/actions/_database-source-utils.test.ts +++ b/templates/content/actions/_database-source-utils.test.ts @@ -25,6 +25,7 @@ import { builderBodyHydrationAttemptIsTerminal, builderBodyNeedsSourceComponentWrite, builderReviewBodyCandidateDocumentIds, + builderSourcePropertyAssignments, builderBodyHydrationVersion, builderBodyUnavailableVersion, builderBodyHydrationNeedsLiveBaseline, @@ -102,6 +103,55 @@ function item(id: string, title: string): ContentDatabaseItem { } describe("database source helpers", () => { + it("keeps the provider-bound property when duplicate local labels collide", () => { + const existingFields = [ + { + propertyId: "local-blurb", + sourceFieldKey: "data.blurb", + provenance: "source field", + }, + { + propertyId: "builder-blurb", + sourceFieldKey: "data.blurb", + provenance: "Builder model field", + }, + ]; + const properties = [ + { + definition: { id: "local-blurb", name: "Blurb", type: "text" }, + }, + { + definition: { id: "builder-blurb", name: "Blurb", type: "text" }, + }, + { + definition: { id: "summary", name: "Summary", type: "text" }, + }, + ]; + + for (const fields of [existingFields, [...existingFields].reverse()]) { + const assignments = builderSourcePropertyAssignments({ + properties, + existingFields: fields, + }); + + expect( + assignments.map(({ property, sourceFieldKey }) => ({ + propertyId: property.definition.id, + sourceFieldKey, + })), + ).toEqual([ + { + propertyId: "builder-blurb", + sourceFieldKey: "data.blurb", + }, + { + propertyId: "summary", + sourceFieldKey: "data.summary", + }, + ]); + } + }); + it("accepts only the persisted Builder continuation offset", () => { const metadataJson = JSON.stringify({ sourceFetchState: "fetching", diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 68fb68cd51..298d1bdf86 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -4413,6 +4413,63 @@ export function mergeBuilderCmsModelFieldsPreservingReferenceModels(args: { }); } +function builderSourceFieldPriority(provenance: string | null) { + return provenance === "Builder model field" + ? 0 + : provenance === "Builder content field" + ? 1 + : 2; +} + +export function builderSourcePropertyAssignments(args: { + properties: Array<{ + definition: { id: string; name: string; type: string }; + }>; + existingFields?: Array<{ + propertyId: string | null; + sourceFieldKey: string; + provenance: string | null; + }>; +}) { + const existingByPropertyId = new Map( + (args.existingFields ?? []) + .filter((field) => field.propertyId) + .map((field) => [field.propertyId!, field]), + ); + const canonicalPropertyBySourceKey = new Map(); + for (const field of [...(args.existingFields ?? [])] + .filter((candidate) => candidate.propertyId) + .sort( + (left, right) => + builderSourceFieldPriority(left.provenance) - + builderSourceFieldPriority(right.provenance), + )) { + const key = field.sourceFieldKey.trim(); + if (!canonicalPropertyBySourceKey.has(key)) { + canonicalPropertyBySourceKey.set(key, field.propertyId!); + } + } + + const claimedSourceKeys = new Set(); + return args.properties.flatMap((property) => { + const existing = existingByPropertyId.get(property.definition.id); + const sourceFieldKey = + existing?.sourceFieldKey ?? + builderCmsSourceFieldKey( + property.definition.id, + property.definition.name, + ); + const normalizedKey = sourceFieldKey.trim(); + const canonicalPropertyId = canonicalPropertyBySourceKey.get(normalizedKey); + if (canonicalPropertyId && canonicalPropertyId !== property.definition.id) { + return []; + } + if (claimedSourceKeys.has(normalizedKey)) return []; + claimedSourceKeys.add(normalizedKey); + return [{ property, sourceFieldKey }]; + }); +} + export async function seedMockSourceFields(args: { sourceId: string; ownerEmail: string; @@ -4425,11 +4482,6 @@ export async function seedMockSourceFields(args: { }) { const db = getDb(); const isBuilder = args.sourceType === "builder-cms"; - const existingBuilderFieldByPropertyId = new Map( - (args.existingFields ?? []) - .filter((field) => isBuilder && field.propertyId) - .map((field) => [field.propertyId!, field]), - ); const builderModelFieldBySourceKey = new Map( (args.builderModelFields ?? []).map((field) => [ `data.${field.name.trim()}`, @@ -4522,40 +4574,36 @@ export async function seedMockSourceFields(args: { // "Source") and only for Builder sources, so a user's own field happening // to be named "Source" — or any non-Builder/local-table source — is left // untouched. - ...args.properties + ...(isBuilder + ? builderSourcePropertyAssignments({ + properties: args.properties, + existingFields: args.existingFields, + }) + : args.properties.map((property) => ({ + property, + sourceFieldKey: `fields.${slugifySourceField(property.definition.name)}`, + })) + ) .filter( - (property) => + ({ property }) => !( isBuilder && property.definition.name === SOURCE_PROPERTY_NAME && property.definition.type === "select" ), ) - .map((property) => ({ + .map(({ property, sourceFieldKey }) => ({ id: crypto.randomUUID(), ownerEmail: args.ownerEmail, sourceId: args.sourceId, propertyId: property.definition.id, localFieldKey: property.definition.id, - sourceFieldKey: isBuilder - ? (existingBuilderFieldByPropertyId.get(property.definition.id) - ?.sourceFieldKey ?? - builderCmsSourceFieldKey( - property.definition.id, - property.definition.name, - )) - : `fields.${slugifySourceField(property.definition.name)}`, + sourceFieldKey, sourceFieldLabel: property.definition.name, sourceFieldType: isBuilder ? normalizeBuilderCmsSourceFieldType( - builderModelFieldBySourceKey.get( - existingBuilderFieldByPropertyId.get(property.definition.id) - ?.sourceFieldKey ?? - builderCmsSourceFieldKey( - property.definition.id, - property.definition.name, - ), - )?.type ?? property.definition.type, + builderModelFieldBySourceKey.get(sourceFieldKey)?.type ?? + property.definition.type, ) : property.definition.type, mappingType: "property", @@ -4659,15 +4707,28 @@ export async function seedMockSourceFields(args: { } const existingFieldBySourceKey = new Map( - (args.existingFields ?? []).map((field) => [ - sourceFieldIdentityKey(field.sourceFieldKey), - field, - ]), + [...(args.existingFields ?? [])] + // `Map` keeps the last value for a duplicate key, so sort the preferred + // provider-discovered field last. This fallback must agree with + // `builderSourcePropertyAssignments`, independent of database row order. + .sort( + (left, right) => + builderSourceFieldPriority(right.provenance) - + builderSourceFieldPriority(left.provenance), + ) + .map((field) => [sourceFieldIdentityKey(field.sourceFieldKey), field]), + ); + const existingFieldByPropertyId = new Map( + (args.existingFields ?? []) + .filter((field) => field.propertyId) + .map((field) => [field.propertyId!, field]), ); const mergedRows = rows.map((row) => { - const existing = existingFieldBySourceKey.get( - sourceFieldIdentityKey(row.sourceFieldKey), - ); + const existing = + (row.propertyId + ? existingFieldByPropertyId.get(row.propertyId) + : undefined) ?? + existingFieldBySourceKey.get(sourceFieldIdentityKey(row.sourceFieldKey)); if (!existing) return row; return { ...row, diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index c4e39162ed..1a69274e54 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -63,6 +63,7 @@ vi.mock("./_builder-cms-read-client.js", async () => { } if (model === "collection-mapped-fields") { return [ + { name: "blurb", type: "string", required: false }, { name: "topics", type: "list", required: false }, { name: "tags", type: "list", required: false }, { name: "customModelField", type: "string", required: false }, @@ -273,6 +274,7 @@ vi.mock("./_builder-cms-read-client.js", async () => { updatedAt: "2026-02-01T00:00:00.000Z", sourceValues: { "data.title": "Mapped fields", + "data.blurb": "Remote Builder blurb", "data.topics": ["AI", "CMS"], "data.tags": ["Agents", "Content"], "data.customModelField": "Arbitrary value", @@ -1169,6 +1171,24 @@ it("materializes topics, tags, and arbitrary Builder model fields", async () => updatedAt: now, }); await db.insert(schema.documentPropertyDefinitions).values([ + { + id: "prop-local-blurb", + ownerEmail: OWNER, + databaseId: "db-mapped-fields", + name: "Blurb", + type: "text", + createdAt: now, + updatedAt: now, + }, + { + id: "prop-builder-blurb", + ownerEmail: OWNER, + databaseId: "db-mapped-fields", + name: "Blurb", + type: "text", + createdAt: now, + updatedAt: now, + }, { id: "prop-topics", ownerEmail: OWNER, @@ -1238,6 +1258,36 @@ it("materializes topics, tags, and arbitrary Builder model fields", async () => updatedAt: now, }); await db.insert(schema.contentDatabaseSourceFields).values([ + { + id: "field-local-blurb", + ownerEmail: OWNER, + sourceId: "source-mapped-fields", + propertyId: "prop-local-blurb", + localFieldKey: "prop-local-blurb", + sourceFieldKey: "data.blurb", + sourceFieldLabel: "Blurb", + sourceFieldType: "text", + mappingType: "property", + writeOwner: "source", + provenance: "source field", + createdAt: now, + updatedAt: now, + }, + { + id: "field-builder-blurb", + ownerEmail: OWNER, + sourceId: "source-mapped-fields", + propertyId: "prop-builder-blurb", + localFieldKey: "prop-builder-blurb", + sourceFieldKey: "data.blurb", + sourceFieldLabel: "Blurb", + sourceFieldType: "text", + mappingType: "property", + writeOwner: "source", + provenance: "Builder model field", + createdAt: now, + updatedAt: now, + }, { id: "field-topics", ownerEmail: OWNER, @@ -1325,6 +1375,7 @@ it("materializes topics, tags, and arbitrary Builder model fields", async () => .from(schema.contentDatabaseSourceRows) .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); expect(JSON.parse(sourceRow.sourceValuesJson)).toMatchObject({ + "data.blurb": "Remote Builder blurb", "data.topics": ["AI", "CMS"], "data.tags": ["Agents", "Content"], "data.customModelField": "Arbitrary value", @@ -1347,6 +1398,7 @@ it("materializes topics, tags, and arbitrary Builder model fields", async () => ]), ), ).toMatchObject({ + "prop-builder-blurb": "Remote Builder blurb", "prop-topics": ["ai", "cms"], "prop-tags": ["agents", "content"], "prop-custom": "Arbitrary value", @@ -1415,6 +1467,18 @@ it("materializes topics, tags, and arbitrary Builder model fields", async () => sourceFieldKey: "data.Status", }, ]); + expect( + statusFieldMappings.filter( + (field: { sourceFieldKey: string }) => + field.sourceFieldKey === "data.blurb", + ), + ).toEqual([ + { + id: "field-builder-blurb", + propertyId: "prop-builder-blurb", + sourceFieldKey: "data.blurb", + }, + ]); const readCall = builderReadMock.calls.find( (call) => call.model === "collection-mapped-fields", ); diff --git a/templates/content/changelog/2026-07-24-builder-source-refresh-now-preserves-the-provider-bound-fiel.md b/templates/content/changelog/2026-07-24-builder-source-refresh-now-preserves-the-provider-bound-fiel.md new file mode 100644 index 0000000000..9a0642ed01 --- /dev/null +++ b/templates/content/changelog/2026-07-24-builder-source-refresh-now-preserves-the-provider-bound-fiel.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-24 +--- + +Builder source refresh now preserves the provider-bound field when local properties share the same label, so follow-up edits remain reviewable. From 64366100edbdfb607551ee495bed66c634e7a05f Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:46:31 -0400 Subject: [PATCH 13/51] fix(content): distinguish Builder prepare from send --- .../BuilderSourceReviewDialog.tsx | 6 ++- .../BuilderSourceReviewDialog.ui.test.tsx | 40 +++++++++++++++++++ .../editor/database/DatabaseView.tsx | 1 + ...inguishes-preparing-a-safe-dry-run-from.md | 6 +++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 templates/content/changelog/2026-07-24-builder-review-now-distinguishes-preparing-a-safe-dry-run-from.md diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx index da14dea749..3fd36a8d9f 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx @@ -291,6 +291,7 @@ export function BuilderSourceReviewDialog({ source, canEdit, pending, + executionPending = false, batchResult = null, error = null, checkedAt, @@ -307,6 +308,7 @@ export function BuilderSourceReviewDialog({ source: ContentDatabaseSource | null; canEdit: boolean; pending: boolean; + executionPending?: boolean; // Optional so the inline-database caller (DatabaseView) that doesn't surface // batch results can still mount the dialog. batchResult?: ExecuteBuilderSourceBatchResponse | null; @@ -485,7 +487,7 @@ export function BuilderSourceReviewDialog({ : review?.result.status, ); const footerHint = pending - ? preparedForExecution + ? executionPending ? "Sending to Builder…" : "Preparing full review…" : hasUnconfirmedUnpublish @@ -517,7 +519,7 @@ export function BuilderSourceReviewDialog({ ? `Push ${selectedReviewRows.length} updates` : "Push update"; const buttonLabel = pending - ? preparedForExecution + ? executionPending ? "Sending…" : "Preparing…" : checked && review?.result.status === "running" diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.ui.test.tsx b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.ui.test.tsx index a66de85720..cf045cdb6a 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.ui.test.tsx +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.ui.test.tsx @@ -312,6 +312,46 @@ describe("BuilderSourceReviewDialog row selection", () => { ); }); + it("never labels dry-run preparation as sending to Builder", () => { + act(() => { + root.render( + , + ); + }); + expect(document.body.textContent).toContain("Preparing full review…"); + expect(document.body.textContent).toContain("Preparing…"); + expect(document.body.textContent).not.toContain("Sending to Builder…"); + + act(() => { + root.render( + , + ); + }); + expect(document.body.textContent).toContain("Sending to Builder…"); + expect(document.body.textContent).toContain("Sending…"); + }); + it("keeps a prepare failure visible and clears it when the selection changes", () => { const onSelectionChange = vi.fn(); act(() => { diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 92fe914899..625036c7db 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -2984,6 +2984,7 @@ function DatabaseTable({ executeBuilderExecution.isPending || cancelPreparedBuilderUpdate.isPending } + executionPending={executeBuilderExecution.isPending} error={ builderReviewError ?? (builderReviewPreviewQuery.error instanceof Error diff --git a/templates/content/changelog/2026-07-24-builder-review-now-distinguishes-preparing-a-safe-dry-run-from.md b/templates/content/changelog/2026-07-24-builder-review-now-distinguishes-preparing-a-safe-dry-run-from.md new file mode 100644 index 0000000000..ee1bc0d65e --- /dev/null +++ b/templates/content/changelog/2026-07-24-builder-review-now-distinguishes-preparing-a-safe-dry-run-from.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-24 +--- + +Builder review now distinguishes preparing a safe dry run from sending the confirmed update to Builder. From bf47b907718e25d4cea0d98c66695b7b2a1bfc8a Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 05:21:04 -0400 Subject: [PATCH 14/51] fix(content): keep multi-word slash commands open --- .../app/components/editor/SlashCommandMenu.test.ts | 6 ++++++ .../app/components/editor/SlashCommandMenu.tsx | 11 ++++++++++- ...sh-search-now-keeps-multi-word-block-names-open.md | 6 ++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 templates/content/changelog/2026-07-24-editor-slash-search-now-keeps-multi-word-block-names-open.md diff --git a/templates/content/app/components/editor/SlashCommandMenu.test.ts b/templates/content/app/components/editor/SlashCommandMenu.test.ts index 6e1b8c533f..6073db7176 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.test.ts +++ b/templates/content/app/components/editor/SlashCommandMenu.test.ts @@ -67,9 +67,15 @@ describe("slash command menu trigger", () => { it("opens for slash commands at the start of a block", () => { expect(parseSlashCommandQuery("/")).toBe(""); expect(parseSlashCommandQuery("/heading")).toBe("heading"); + expect(parseSlashCommandQuery("/heading 2")).toBe("heading 2"); + expect(parseSlashCommandQuery("/numbered list")).toBe("numbered list"); expect(parseSlashCommandQuery(" /table")).toBe("table"); }); + it("still yields multi-word generate prompts to inline submission", () => { + expect(parseSlashCommandQuery("/generate outline this PRD")).toBeNull(); + }); + it("does not open for slashes embedded in normal prose", () => { expect(parseSlashCommandQuery("hello/world")).toBeNull(); expect(parseSlashCommandQuery("hello /world")).toBeNull(); diff --git a/templates/content/app/components/editor/SlashCommandMenu.tsx b/templates/content/app/components/editor/SlashCommandMenu.tsx index ac018e1692..78e2833561 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.tsx +++ b/templates/content/app/components/editor/SlashCommandMenu.tsx @@ -219,7 +219,16 @@ export function parseInlineGeneratePrompt(textBeforeCursor: string) { } export function parseSlashCommandQuery(textBeforeCursor: string) { - return textBeforeCursor.match(/^\s*\/([a-zA-Z0-9]*)$/)?.[1] ?? null; + const match = textBeforeCursor.match( + /^\s*\/([a-zA-Z0-9][a-zA-Z0-9 _-]*|)\s*$/, + ); + if (!match) return null; + const rawQuery = match[1] ?? ""; + // `/generate ` intentionally leaves the menu so Enter can submit the + // inline prompt. Other multi-word labels (for example `/heading 2`) remain + // searchable instead of turning into literal editor text at the first space. + if (/^generate\s+/i.test(rawQuery)) return null; + return rawQuery.trim(); } export function inlineDatabaseBlockContent( diff --git a/templates/content/changelog/2026-07-24-editor-slash-search-now-keeps-multi-word-block-names-open.md b/templates/content/changelog/2026-07-24-editor-slash-search-now-keeps-multi-word-block-names-open.md new file mode 100644 index 0000000000..f725cfef72 --- /dev/null +++ b/templates/content/changelog/2026-07-24-editor-slash-search-now-keeps-multi-word-block-names-open.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-24 +--- + +Editor slash search now keeps multi-word block names such as Heading 2 open instead of inserting them as literal text. From e517395512323ababb7203ddc4c1a33f16d9f85e Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 05:51:46 -0400 Subject: [PATCH 15/51] fix(content): activate slash commands on click --- .../editor/SlashCommandMenu.test.ts | 61 +++++++++++++++++++ .../components/editor/SlashCommandMenu.tsx | 4 +- ...w-activate-reliably-when-selected-with-.md | 6 ++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 templates/content/changelog/2026-07-24-slash-menu-choices-now-activate-reliably-when-selected-with-.md diff --git a/templates/content/app/components/editor/SlashCommandMenu.test.ts b/templates/content/app/components/editor/SlashCommandMenu.test.ts index 6073db7176..a9046fb3f7 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.test.ts +++ b/templates/content/app/components/editor/SlashCommandMenu.test.ts @@ -5,10 +5,13 @@ import { join } from "node:path"; import { Editor } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; import { describe, expect, it, vi } from "vitest"; import { buildHeadingCommands, + CommandButton, CONTENT_HEADING_LEVELS, equationNodeContent, getEquationInsertionRange, @@ -20,6 +23,10 @@ import { setPlainTextBlock, } from "./SlashCommandMenu"; +function TestIcon() { + return createElement("svg"); +} + function readSlashCommandMenuSource() { return readFileSync( join(process.cwd(), "app/components/editor/SlashCommandMenu.tsx"), @@ -83,6 +90,60 @@ describe("slash command menu trigger", () => { }); }); +describe("slash command pointer activation", () => { + it("preserves the editor selection on pointer down and executes on click", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const onExecute = vi.fn(); + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + + try { + act(() => { + root.render( + createElement(CommandButton, { + cmd: { + title: "Code Block", + description: "Insert a code block", + icon: TestIcon, + action: vi.fn(), + }, + isSelected: true, + onExecute, + onHover: vi.fn(), + }), + ); + }); + + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + const mouseDown = new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + }); + act(() => button?.dispatchEvent(mouseDown)); + expect(mouseDown.defaultPrevented).toBe(true); + expect(onExecute).not.toHaveBeenCalled(); + + act(() => + button?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ), + ); + expect(onExecute).toHaveBeenCalledTimes(1); + } finally { + act(() => root.unmount()); + container.remove(); + actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + } + }); +}); + describe("heading slash commands", () => { it("offers all six HTML heading levels for insert and turn-into flows", () => { expect(CONTENT_HEADING_LEVELS).toEqual([1, 2, 3, 4, 5, 6]); diff --git a/templates/content/app/components/editor/SlashCommandMenu.tsx b/templates/content/app/components/editor/SlashCommandMenu.tsx index 78e2833561..84f2a330d5 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.tsx +++ b/templates/content/app/components/editor/SlashCommandMenu.tsx @@ -1302,7 +1302,7 @@ export function SlashCommandMenu({ ); } -function CommandButton({ +export function CommandButton({ cmd, isSelected, buttonRef, @@ -1320,8 +1320,8 @@ function CommandButton({ ref={buttonRef} onMouseDown={(event) => { event.preventDefault(); - onExecute(); }} + onClick={onExecute} onMouseEnter={onHover} className={cn( "flex min-h-9 w-full items-center gap-3 px-3 py-1 text-left transition-colors", diff --git a/templates/content/changelog/2026-07-24-slash-menu-choices-now-activate-reliably-when-selected-with-.md b/templates/content/changelog/2026-07-24-slash-menu-choices-now-activate-reliably-when-selected-with-.md new file mode 100644 index 0000000000..0863c0f948 --- /dev/null +++ b/templates/content/changelog/2026-07-24-slash-menu-choices-now-activate-reliably-when-selected-with-.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-24 +--- + +Slash-menu choices now activate reliably when selected with a pointer. From 27a12f1b397461d99a93b7793c046bf8288b70c5 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:01:06 -0400 Subject: [PATCH 16/51] fix(content): handle slash command pointer release --- .../editor/SlashCommandMenu.test.ts | 53 +++++++++++++++++-- .../components/editor/SlashCommandMenu.tsx | 18 ++++++- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/templates/content/app/components/editor/SlashCommandMenu.test.ts b/templates/content/app/components/editor/SlashCommandMenu.test.ts index a9046fb3f7..67f4e45820 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.test.ts +++ b/templates/content/app/components/editor/SlashCommandMenu.test.ts @@ -91,7 +91,7 @@ describe("slash command menu trigger", () => { }); describe("slash command pointer activation", () => { - it("preserves the editor selection on pointer down and executes on click", () => { + it("preserves selection and executes once on pointer release", () => { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -122,14 +122,22 @@ describe("slash command pointer activation", () => { const button = container.querySelector("button"); expect(button).not.toBeNull(); - const mouseDown = new MouseEvent("mousedown", { + const pointerDown = new MouseEvent("pointerdown", { bubbles: true, cancelable: true, }); - act(() => button?.dispatchEvent(mouseDown)); - expect(mouseDown.defaultPrevented).toBe(true); + act(() => button?.dispatchEvent(pointerDown)); + expect(pointerDown.defaultPrevented).toBe(true); expect(onExecute).not.toHaveBeenCalled(); + const pointerUp = new MouseEvent("pointerup", { + bubbles: true, + cancelable: true, + }); + act(() => button?.dispatchEvent(pointerUp)); + expect(pointerUp.defaultPrevented).toBe(true); + expect(onExecute).toHaveBeenCalledTimes(1); + act(() => button?.dispatchEvent( new MouseEvent("click", { bubbles: true, cancelable: true }), @@ -142,6 +150,43 @@ describe("slash command pointer activation", () => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; } }); + + it("keeps click as a keyboard and assistive-activation fallback", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const onExecute = vi.fn(); + + try { + act(() => { + root.render( + createElement(CommandButton, { + cmd: { + title: "Code Block", + description: "Insert a code block", + icon: TestIcon, + action: vi.fn(), + }, + isSelected: true, + onExecute, + onHover: vi.fn(), + }), + ); + }); + + act(() => + container + .querySelector("button") + ?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ), + ); + expect(onExecute).toHaveBeenCalledTimes(1); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); }); describe("heading slash commands", () => { diff --git a/templates/content/app/components/editor/SlashCommandMenu.tsx b/templates/content/app/components/editor/SlashCommandMenu.tsx index 84f2a330d5..9172fb1eff 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.tsx +++ b/templates/content/app/components/editor/SlashCommandMenu.tsx @@ -1315,13 +1315,29 @@ export function CommandButton({ onExecute: () => void; onHover: () => void; }) { + const executedFromPointerRef = useRef(false); + return ( - ) : ( - - {displayLabel} - - )} - {showPicker && isEditable && ( -
- setFilter(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") closePicker(); - if (e.key === "Enter" && filteredLanguages.length > 0) { - updateAttributes({ - language: filteredLanguages[0].value || "", - }); - closePicker(); - } - }} - placeholder={t("editor.searchLanguages")} - className="notion-code-lang-search" - /> -
- {filteredLanguages.map((lang) => ( - - ))} -
-
- )} - - -
-        
-      
- - ); -} - +/** + * Content's fenced code block. + * + * Keep the node on Tiptap's native DOM renderer. Mounting the former React + * node view could synchronously lock a collaborative editor as soon as a code + * block entered the document. The native renderer preserves the same + * `codeBlock` schema, language attribute, lowlight decorations, markdown + * round-trip, and keyboard behavior without a second React render tree inside + * ProseMirror. + */ export const CodeBlock = CodeBlockLowlight.extend({ - addNodeView() { - return ReactNodeViewRenderer(CodeBlockView); - }, addKeyboardShortcuts() { return { ...this.parent?.(), diff --git a/templates/content/changelog/2026-07-24-slash-menu-block-choices-no-longer-stall-pointer-interaction.md b/templates/content/changelog/2026-07-24-slash-menu-block-choices-no-longer-stall-pointer-interaction.md index 00e07d8b82..56efc79475 100644 --- a/templates/content/changelog/2026-07-24-slash-menu-block-choices-no-longer-stall-pointer-interaction.md +++ b/templates/content/changelog/2026-07-24-slash-menu-block-choices-no-longer-stall-pointer-interaction.md @@ -3,4 +3,4 @@ type: fixed date: 2026-07-24 --- -Slash-menu block choices now finish even when closing the menu unmounts the selected choice. +Code Block and other slash-menu choices now finish without hanging the collaborative editor, even when closing the menu unmounts the selected choice. From caedc1e6280a012561021eaf016d190da4d763cd Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:26:44 -0400 Subject: [PATCH 24/51] fix(content): make code block slash insertion atomic --- .../editor/SlashCommandMenu.test.ts | 26 +++++++++++++++++++ .../components/editor/SlashCommandMenu.tsx | 17 ++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/templates/content/app/components/editor/SlashCommandMenu.test.ts b/templates/content/app/components/editor/SlashCommandMenu.test.ts index 8aeb9403e5..ebb514559f 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.test.ts +++ b/templates/content/app/components/editor/SlashCommandMenu.test.ts @@ -20,6 +20,7 @@ import { insertInlineDatabaseBlock, parseSlashCommandQuery, parseInlineGeneratePrompt, + setCodeBlockFromSlashCommand, setPlainTextBlock, } from "./SlashCommandMenu"; @@ -194,6 +195,31 @@ describe("slash command pointer activation", () => { }); }); +describe("code block slash command", () => { + it("deletes the slash query and converts the block in one command chain", () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "

/code

", + }); + + try { + editor.commands.setTextSelection(6); + expect(setCodeBlockFromSlashCommand(editor, { from: 1, to: 6 })).toBe( + true, + ); + expect(editor.getJSON()).toEqual({ + type: "doc", + content: [ + { type: "codeBlock", attrs: { language: null } }, + { type: "paragraph" }, + ], + }); + } finally { + editor.destroy(); + } + }); +}); + describe("heading slash commands", () => { it("offers all six HTML heading levels for insert and turn-into flows", () => { expect(CONTENT_HEADING_LEVELS).toEqual([1, 2, 3, 4, 5, 6]); diff --git a/templates/content/app/components/editor/SlashCommandMenu.tsx b/templates/content/app/components/editor/SlashCommandMenu.tsx index 905dbf4c55..fc97905f1d 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.tsx +++ b/templates/content/app/components/editor/SlashCommandMenu.tsx @@ -302,6 +302,15 @@ export function getEquationInsertionRange( : slashRange; } +export function setCodeBlockFromSlashCommand( + editor: Editor, + slashRange: { from: number; to: number } | null, +) { + const chain = editor.chain().focus(); + if (slashRange) chain.deleteRange(slashRange); + return chain.setCodeBlock().run(); +} + const commands: CommandTemplate[] = [ { titleKey: "editor.slash.text", @@ -354,7 +363,9 @@ const commands: CommandTemplate[] = [ descriptionKey: "editor.slash.codeBlockDescription", shortcut: "```", icon: IconCode, - action: (editor) => editor.chain().focus().toggleCodeBlock().run(), + preserveSlashRange: true, + action: (editor, { slashRange }) => + setCodeBlockFromSlashCommand(editor, slashRange), }, { titleKey: "editor.slash.quote", @@ -459,7 +470,9 @@ const turnIntoCommands: CommandTemplate[] = [ descriptionKey: "editor.slash.codeBlockDescription", shortcut: "```", icon: IconCode, - action: (editor) => editor.chain().focus().toggleCodeBlock().run(), + preserveSlashRange: true, + action: (editor, { slashRange }) => + setCodeBlockFromSlashCommand(editor, slashRange), }, { titleKey: "editor.slash.quote", From 52e746355ca0ff67430d8fe89aa56c8b863b762e Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:11:47 -0400 Subject: [PATCH 25/51] fix(content): keep slash menu available during reconciliation --- .../editor/SlashCommandMenu.test.ts | 55 +++++++++++++++++ .../components/editor/SlashCommandMenu.tsx | 59 ++++++++++++------- 2 files changed, 94 insertions(+), 20 deletions(-) diff --git a/templates/content/app/components/editor/SlashCommandMenu.test.ts b/templates/content/app/components/editor/SlashCommandMenu.test.ts index ebb514559f..b4f5dcf2bc 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.test.ts +++ b/templates/content/app/components/editor/SlashCommandMenu.test.ts @@ -3,10 +3,13 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Editor } from "@tiptap/core"; +import { EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { @@ -20,6 +23,7 @@ import { insertInlineDatabaseBlock, parseSlashCommandQuery, parseInlineGeneratePrompt, + SlashCommandMenu, setCodeBlockFromSlashCommand, setPlainTextBlock, } from "./SlashCommandMenu"; @@ -89,6 +93,57 @@ describe("slash command menu trigger", () => { expect(parseSlashCommandQuery("hello /world")).toBeNull(); expect(parseSlashCommandQuery("open https://example.com/path")).toBeNull(); }); + + it("reopens in a trailing paragraph after a code block", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const editor = new Editor({ + extensions: [StarterKit], + content: "
const answer = 42;

", + }); + + try { + await act(async () => { + root.render( + createElement( + MemoryRouter, + null, + createElement( + QueryClientProvider, + { client: queryClient }, + createElement( + "div", + { className: "visual-editor-wrapper" }, + createElement(EditorContent, { editor }), + createElement(SlashCommandMenu, { editor }), + ), + ), + ), + ); + await Promise.resolve(); + }); + + vi.spyOn(editor.view, "coordsAtPos").mockImplementationOnce(() => { + throw new RangeError("DOM position is reconciling"); + }); + act(() => { + editor.commands.focus("end"); + editor.commands.insertContent("/"); + }); + await act(async () => Promise.resolve()); + + expect(container.querySelector(".slash-command-menu")).not.toBeNull(); + } finally { + await act(async () => root.unmount()); + editor.destroy(); + queryClient.clear(); + container.remove(); + } + }); }); describe("slash command pointer activation", () => { diff --git a/templates/content/app/components/editor/SlashCommandMenu.tsx b/templates/content/app/components/editor/SlashCommandMenu.tsx index fc97905f1d..9fb13c40f6 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.tsx +++ b/templates/content/app/components/editor/SlashCommandMenu.tsx @@ -73,6 +73,43 @@ interface EditorMenuPosition { left: number; } +export function getSlashMenuPosition(editor: Editor): EditorMenuPosition { + const wrapper = editor.view.dom.closest(".visual-editor-wrapper"); + const containerRect = ( + wrapper instanceof HTMLElement ? wrapper : editor.view.dom + ).getBoundingClientRect(); + + try { + const coords = editor.view.coordsAtPos(editor.state.selection.from); + return { + top: coords.bottom - containerRect.top + 4, + left: coords.left - containerRect.left, + }; + } catch { + // Collaborative reconciliation can briefly leave ProseMirror's DOM mapping + // behind the document selection. The slash transaction is still valid; use + // its nearest DOM node (or the editor origin) so the command menu remains + // available instead of turning the user's slash into inert text. + try { + const domAtSelection = editor.view.domAtPos(editor.state.selection.from); + const element = + domAtSelection.node instanceof Element + ? domAtSelection.node + : domAtSelection.node.parentElement; + const rect = element?.getBoundingClientRect(); + if (rect) { + return { + top: rect.bottom - containerRect.top + 4, + left: rect.left - containerRect.left, + }; + } + } catch { + // The editor origin below is a safe, visible final fallback. + } + return { top: 4, left: 0 }; + } +} + interface EquationDraft { displayMode: boolean; insertionRange: { from: number; to: number }; @@ -567,16 +604,7 @@ export function SlashCommandMenu({ ); const getSelectionMenuPosition = useCallback(() => { - const coords = editor.view.coordsAtPos(editor.state.selection.from); - const editorRect = editor.view.dom - .closest(".visual-editor-wrapper") - ?.getBoundingClientRect(); - if (!editorRect) return null; - - return { - top: coords.bottom - editorRect.top + 4, - left: coords.left - editorRect.left, - }; + return getSlashMenuPosition(editor); }, [editor]); const openGeneratePopover = useCallback( @@ -1066,16 +1094,7 @@ export function SlashCommandMenu({ const slashAtBlockStart = offsetInParent === 0; setIsTurnInto(slashAtBlockStart && blockHasOtherContent); - const coords = editor.view.coordsAtPos(from); - const editorRect = editor.view.dom - .closest(".visual-editor-wrapper") - ?.getBoundingClientRect(); - if (editorRect) { - setPosition({ - top: coords.bottom - editorRect.top + 4, - left: coords.left - editorRect.left, - }); - } + setPosition(getSlashMenuPosition(editor)); setIsOpen(true); } else { if (isOpen) { From 5d30561c0ac903c83cd9c2356b3ddcfcfab6492c Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:38:14 -0400 Subject: [PATCH 26/51] fix(content): clamp stale collaborative placeholder positions --- .../editor/VisualEditor.markdown.test.ts | 17 +++++++++++++++++ .../app/components/editor/VisualEditor.tsx | 10 ++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/templates/content/app/components/editor/VisualEditor.markdown.test.ts b/templates/content/app/components/editor/VisualEditor.markdown.test.ts index 3ce393b815..ab0da2a4e0 100644 --- a/templates/content/app/components/editor/VisualEditor.markdown.test.ts +++ b/templates/content/app/components/editor/VisualEditor.markdown.test.ts @@ -26,6 +26,7 @@ import { createVisualEditorExtensions, EmptyLineParagraph, getRecentEditPresenceMarkerRect, + hasAncestorType, parseNfmForCollabReconcile, uploadAndInsertAudioFiles, uploadAndInsertImageFiles, @@ -70,6 +71,22 @@ function waitForDeferredCallback() { return new Promise((resolve) => setTimeout(resolve, 0)); } +describe("placeholder ancestry", () => { + it("clamps stale collaborative positions to the live document", () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "

Short paragraph

", + }); + try { + expect(() => hasAncestorType(editor, 180, "blockquote")).not.toThrow(); + expect(hasAncestorType(editor, 180, "blockquote")).toBe(false); + expect(() => hasAncestorType(editor, -180, "blockquote")).not.toThrow(); + } finally { + editor.destroy(); + } + }); +}); + function triggerTextInput(editor: Editor, text: string) { const { from, to } = editor.state.selection; let handled = false; diff --git a/templates/content/app/components/editor/VisualEditor.tsx b/templates/content/app/components/editor/VisualEditor.tsx index 3a14dd6a33..c2f745fe82 100644 --- a/templates/content/app/components/editor/VisualEditor.tsx +++ b/templates/content/app/components/editor/VisualEditor.tsx @@ -977,17 +977,15 @@ interface VisualEditorExtensionOptions { emptyBlockPlaceholder?: string; } -function hasAncestorType( +export function hasAncestorType( editor: CoreEditor, pos: number, typeName: string, ): boolean { const doc = editor.state.doc; - const positions = [ - Math.max(0, pos - 1), - pos, - Math.min(doc.content.size, pos + 1), - ]; + const clampPosition = (candidate: number) => + Math.min(doc.content.size, Math.max(0, candidate)); + const positions = [...new Set([pos - 1, pos, pos + 1].map(clampPosition))]; return positions.some((candidatePos) => { const resolvedPos = doc.resolve(candidatePos); From 84951528ccb244f156eb27241a68c5bf5db8770e Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:01:18 -0400 Subject: [PATCH 27/51] fix(content): retry transient Builder pagination --- .../content/actions/_database-source-utils.ts | 8 +- .../resync-content-database-source.db.test.ts | 119 ++++++++++++++++++ .../editor/database/DatabaseView.test.ts | 7 ++ .../editor/database/DatabaseView.tsx | 49 ++++++-- ...s-now-recover-from-a-transient-paginati.md | 6 + 5 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 templates/content/changelog/2026-07-24-builder-source-imports-now-recover-from-a-transient-paginati.md diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 298d1bdf86..1583b48de7 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -5786,6 +5786,12 @@ export async function resyncBuilderCmsSourceSnapshot(args: { limit: args.runFullRefresh ? 10_000 : undefined, offset: continueOffset, }); + if (builderRead.state === "error") { + throw new Error( + builderRead.message ?? + "Builder CMS read failed; the previous source snapshot was preserved.", + ); + } const incrementalRead = builderRead.state === "live" && (builderRead.progress?.partial === true || @@ -6112,7 +6118,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { message: builderRead.message, builderModelFields, progress: builderRead.progress, - sourceFetchState: builderRead.state === "error" ? "error" : "idle", + sourceFetchState: "idle", activeReadSourceRowIds: undefined, syncState: "idle", refreshClaimId: args.refreshClaimId, diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index 1a69274e54..23c8146df8 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -228,6 +228,25 @@ vi.mock("./_builder-cms-read-client.js", async () => { }, }; } + if (model === "collection-read-error-continuation") { + const startOffset = offset ?? 500; + return { + state: "error", + entries: [], + fetchedAt: "2026-02-01T00:00:00.000Z", + message: "Builder CMS read failed with HTTP 503.", + progress: { + requestedLimit: 500, + pageSize: 100, + startOffset, + nextOffset: startOffset, + fetchedEntryCount: startOffset, + hasMore: false, + partial: false, + readMode: "builder-api", + }, + }; + } if (model === "collection-large-597") { const allEntries = Array.from({ length: 597 }, (_, index) => ({ id: `entry-large-${index + 1}`, @@ -781,6 +800,106 @@ it("atomically grants one Builder continuation claim per persisted offset", asyn }); }); +it("preserves a partial Builder snapshot when a continuation read fails", async () => { + const db = getDb(); + const createdAt = "2026-01-01T00:00:00.000Z"; + const metadataJson = JSON.stringify({ + sourceFetchState: "fetching", + lastReadHasMore: true, + lastReadNextOffset: 500, + lastReadFetchedEntryCount: 500, + activeReadSourceRowIds: ["entry-preserved"], + }); + await db.insert(schema.documents).values([ + { + id: "doc-read-error-db", + ownerEmail: OWNER, + title: "Read error DB", + createdAt, + updatedAt: createdAt, + }, + { + id: "doc-read-error-row", + ownerEmail: OWNER, + parentId: "doc-read-error-db", + title: "Preserved row", + createdAt, + updatedAt: createdAt, + }, + ]); + await db.insert(schema.contentDatabases).values({ + id: "db-read-error", + ownerEmail: OWNER, + documentId: "doc-read-error-db", + title: "Read error DB", + createdAt, + updatedAt: createdAt, + }); + await db.insert(schema.contentDatabaseItems).values({ + id: "item-read-error-row", + ownerEmail: OWNER, + databaseId: "db-read-error", + documentId: "doc-read-error-row", + position: 0, + createdAt, + updatedAt: createdAt, + }); + await db.insert(schema.contentDatabaseSources).values({ + id: "source-read-error", + ownerEmail: OWNER, + databaseId: "db-read-error", + sourceType: "builder-cms", + sourceName: "Read error source", + sourceTable: "collection-read-error-continuation", + syncState: "refreshing", + freshness: "stale", + metadataJson, + createdAt, + updatedAt: createdAt, + }); + await db.insert(schema.contentDatabaseSourceRows).values({ + id: "source-row-read-error", + ownerEmail: OWNER, + sourceId: "source-read-error", + databaseItemId: "item-read-error-row", + documentId: "doc-read-error-row", + sourceRowId: "entry-preserved", + sourceQualifiedId: + "builder-cms://collection-read-error-continuation/entry-preserved", + sourceDisplayKey: "Preserved row", + sourceValuesJson: JSON.stringify({ "data.title": "Preserved row" }), + provenance: "Builder CMS read adapter", + freshness: "fresh", + createdAt, + updatedAt: createdAt, + }); + const [database] = await db + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, "db-read-error")); + const [source] = await db + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, "source-read-error")); + + await expect( + resync({ database, source, now: "2026-02-01T00:00:00.000Z" }), + ).rejects.toThrow("HTTP 503"); + + const rows = await db + .select() + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); + const [preservedSource] = await db + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, source.id)); + expect(rows).toHaveLength(1); + expect(rows[0]?.sourceRowId).toBe("entry-preserved"); + expect(preservedSource.metadataJson).toBe(metadataJson); + expect(preservedSource.syncState).toBe("refreshing"); +}); + it("preserves an established source snapshot when Builder unexpectedly returns zero entries", async () => { builderReadMock.mode = "full"; builderReadMock.calls = []; diff --git a/templates/content/app/components/editor/database/DatabaseView.test.ts b/templates/content/app/components/editor/database/DatabaseView.test.ts index b39bab5b7e..a2a8d250e0 100644 --- a/templates/content/app/components/editor/database/DatabaseView.test.ts +++ b/templates/content/app/components/editor/database/DatabaseView.test.ts @@ -22,6 +22,7 @@ import { databaseBulkMultiSelectValueAfterOperation, builderSourceContinuationKey, builderSourceContinuationFetchedCountDetail, + builderSourceContinuationFailureDecision, builderSourceContinuationWatchdogDelay, builderSourceContinuationProgressPercent, builderSourceContinuationWatchdogDecision, @@ -388,6 +389,12 @@ describe("Builder source continuation state", () => { expect(builderSourceContinuationWatchdogDelay(20)).toBe(30_000); }); + it("retries one transient continuation failure before surfacing an error", () => { + expect(builderSourceContinuationFailureDecision(1)).toBe("retry"); + expect(builderSourceContinuationFailureDecision(2)).toBe("error"); + expect(builderSourceContinuationFailureDecision(20)).toBe("error"); + }); + it("keeps a direct retry attempted until the watchdog backoff retries it", () => { const source = { id: "source-builder", diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 625036c7db..7521e6efe4 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -910,6 +910,7 @@ function DatabaseTable({ ); const autoContinueBuilderSourceRef = useRef>(new Set()); const builderContinuationWatchdogRef = useRef>(new Map()); + const builderContinuationFailureRef = useRef>(new Map()); const refreshSourceInFlightRef = useRef(null); const hydrationSourceInFlightRef = useRef(null); const workspaceSelectionQueueRef = useRef(createContentSpaceSelectionQueue()); @@ -1119,6 +1120,7 @@ function DatabaseTable({ sourceId: string, onError?: () => void, expectedBuilderContinuationOffset?: number, + onSuccess?: () => void, ) => { if (!acquireDatabaseSourceOperation(refreshSourceInFlightRef, sourceId)) { return false; @@ -1131,6 +1133,7 @@ function DatabaseTable({ }, { onError, + onSuccess, onSettled: () => { releaseDatabaseSourceOperation(refreshSourceInFlightRef, sourceId); }, @@ -1140,6 +1143,25 @@ function DatabaseTable({ }, [document.id, refreshSource.mutate], ); + const handleBuilderContinuationError = useCallback( + (continuationKey: string) => { + const failures = + (builderContinuationFailureRef.current.get(continuationKey) ?? 0) + 1; + builderContinuationFailureRef.current.set(continuationKey, failures); + if (builderSourceContinuationFailureDecision(failures) === "error") { + setBuilderContinuationClientErrorKeys((current) => + addUniqueKey(current, continuationKey), + ); + } + }, + [], + ); + const handleBuilderContinuationSuccess = useCallback( + (continuationKey: string) => { + builderContinuationFailureRef.current.delete(continuationKey); + }, + [], + ); const runBuilderHydration = useCallback( ( sourceId: string, @@ -1207,11 +1229,9 @@ function DatabaseTable({ if ( !runSourceRefresh( candidate.id, - () => - setBuilderContinuationClientErrorKeys((current) => - addUniqueKey(current, continuationKey), - ), + () => handleBuilderContinuationError(continuationKey), candidate.metadata.lastReadNextOffset, + () => handleBuilderContinuationSuccess(continuationKey), ) ) { autoContinueBuilderSourceRef.current.delete(continuationKey); @@ -1221,6 +1241,8 @@ function DatabaseTable({ builderSources, canEdit, isActive, + handleBuilderContinuationError, + handleBuilderContinuationSuccess, refreshSource.isPending, runSourceRefresh, ]); @@ -1286,11 +1308,9 @@ function DatabaseTable({ if ( runSourceRefresh( candidate.id, - () => - setBuilderContinuationClientErrorKeys((current) => - addUniqueKey(current, continuationKey), - ), + () => handleBuilderContinuationError(continuationKey), candidate.metadata.lastReadNextOffset, + () => handleBuilderContinuationSuccess(continuationKey), ) ) { builderContinuationWatchdogRef.current.set( @@ -1309,6 +1329,8 @@ function DatabaseTable({ builderSources, canEdit, isActive, + handleBuilderContinuationError, + handleBuilderContinuationSuccess, refreshSource.isPending, runSourceRefresh, ]); @@ -2559,13 +2581,12 @@ function DatabaseTable({ continuationKey, ); builderContinuationWatchdogRef.current.set(continuationKey, 0); + builderContinuationFailureRef.current.delete(continuationKey); runSourceRefresh( builderSource.id, - () => - setBuilderContinuationClientErrorKeys((current) => - addUniqueKey(current, continuationKey), - ), + () => handleBuilderContinuationError(continuationKey), builderSource.metadata.lastReadNextOffset, + () => handleBuilderContinuationSuccess(continuationKey), ); }} /> @@ -10213,6 +10234,10 @@ export function builderSourceContinuationWatchdogDecision(refires: number) { return "refire"; } +export function builderSourceContinuationFailureDecision(failures: number) { + return failures <= 1 ? "retry" : "error"; +} + export function builderSourceContinuationWatchdogDelay(refires: number) { return Math.min( BUILDER_SOURCE_CONTINUATION_MAX_BACKOFF_MS, diff --git a/templates/content/changelog/2026-07-24-builder-source-imports-now-recover-from-a-transient-paginati.md b/templates/content/changelog/2026-07-24-builder-source-imports-now-recover-from-a-transient-paginati.md new file mode 100644 index 0000000000..d65f4a0200 --- /dev/null +++ b/templates/content/changelog/2026-07-24-builder-source-imports-now-recover-from-a-transient-paginati.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-24 +--- + +Builder source imports now recover from a transient pagination failure without discarding already loaded rows. From c653a1d76c605232047b775ff1e2588af6087e88 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:22:51 -0400 Subject: [PATCH 28/51] fix(content): reconcile Builder write controls --- templates/content/app/hooks/use-content-database.ts | 3 ++- ...-write-policy-controls-now-reflect-successful-changes.md | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 templates/content/changelog/2026-07-24-builder-write-policy-controls-now-reflect-successful-changes.md diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index cb37c67235..639e1779f7 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -1218,7 +1218,8 @@ export function useSetContentDatabaseSourceWriteMode(documentId: string) { ContentDatabaseResponse, SetContentDatabaseSourceWriteModeRequest >("set-content-database-source-write-mode", { - onSuccess: () => { + onSuccess: (data) => { + writeContentDatabaseResponseToCache(queryClient, documentId, data); queryClient.invalidateQueries({ queryKey: contentDatabaseQueryKey(documentId), }); diff --git a/templates/content/changelog/2026-07-24-builder-write-policy-controls-now-reflect-successful-changes.md b/templates/content/changelog/2026-07-24-builder-write-policy-controls-now-reflect-successful-changes.md new file mode 100644 index 0000000000..bba9d165ad --- /dev/null +++ b/templates/content/changelog/2026-07-24-builder-write-policy-controls-now-reflect-successful-changes.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-24 +--- + +Builder write policy controls now reflect successful changes immediately. From 86107f2e97feab8c5d1c9133eb5e6ddbb1f5c195 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:39:38 -0400 Subject: [PATCH 29/51] fix(content): optimistically update Builder policy --- .../app/hooks/use-content-database.test.ts | 53 +++++++++++++ .../content/app/hooks/use-content-database.ts | 74 +++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/templates/content/app/hooks/use-content-database.test.ts b/templates/content/app/hooks/use-content-database.test.ts index dc71e91701..f06565abe2 100644 --- a/templates/content/app/hooks/use-content-database.test.ts +++ b/templates/content/app/hooks/use-content-database.test.ts @@ -9,6 +9,7 @@ import { applyDocumentPropertiesToDatabaseResponse, applyDocumentPropertyValueToDatabaseResponse, applyOptimisticItemToContentDatabase, + applyOptimisticBuilderWriteMode, applyOptimisticSourceFieldPropertyToDatabaseResponse, applySourceFieldPropertyToDatabaseResponse, clearDeletedContentDatabaseFromCache, @@ -217,6 +218,58 @@ function databaseResponse(): ContentDatabaseResponse { }; } +describe("applyOptimisticBuilderWriteMode", () => { + it("updates the active Builder policy without replacing database rows", () => { + const current = databaseResponse(); + current.sources = [current.source!]; + + const updated = applyOptimisticBuilderWriteMode(current, { + documentId: "database-page", + sourceId: "source", + writeMode: "publish_updates", + allowPublicationTransitions: true, + }); + + expect(updated?.items).toBe(current.items); + expect(updated?.source).toMatchObject({ + capabilities: { liveWritesEnabled: true }, + metadata: { + writeMode: "publish_updates", + allowPublicationTransitions: true, + allowedWriteModes: ["autosave", "publish"], + allowPublishWrites: true, + pushMode: "publish", + }, + }); + expect(updated?.sources?.[0]).toMatchObject(updated?.source ?? {}); + }); + + it("optimistically disables publication transitions for read-only mode", () => { + const current = applyOptimisticBuilderWriteMode(databaseResponse(), { + sourceId: "source", + writeMode: "publish_updates", + allowPublicationTransitions: true, + }); + + const updated = applyOptimisticBuilderWriteMode(current, { + sourceId: "source", + writeMode: "read_only", + allowPublicationTransitions: true, + }); + + expect(updated?.source).toMatchObject({ + capabilities: { liveWritesEnabled: false }, + metadata: { + writeMode: "read_only", + allowPublicationTransitions: false, + allowedWriteModes: [], + allowPublishWrites: false, + pushMode: "none", + }, + }); + }); +}); + function sourceFieldPatch(): ContentDatabaseSourceFieldPropertyResponse { return { databaseId: "database", diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index 639e1779f7..4741b9e34f 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -170,6 +170,57 @@ export function writeContentDatabaseResponseToCache( ); } +export function applyOptimisticBuilderWriteMode( + current: ContentDatabaseResponse | undefined, + request: SetContentDatabaseSourceWriteModeRequest, +) { + if (!current || !request.writeMode) return current; + const sourceId = request.sourceId ?? current.source?.id; + if (!sourceId) return current; + const writeMode = request.writeMode; + const liveWritesEnabled = writeMode !== "read_only"; + const allowPublicationTransitions = + writeMode === "publish_updates" && + request.allowPublicationTransitions === true; + const allowedWriteModes = + writeMode === "publish_updates" + ? (["autosave", "publish"] as const) + : writeMode === "stage_only" + ? (["autosave"] as const) + : ([] as const); + const patchSource = ( + source: ContentDatabaseResponse["source"], + ): ContentDatabaseResponse["source"] => + source?.id === sourceId + ? { + ...source, + capabilities: { + ...source.capabilities, + liveWritesEnabled, + }, + metadata: { + ...source.metadata, + writeMode, + allowPublicationTransitions, + allowedWriteModes: [...allowedWriteModes], + allowDraftWrites: false, + allowPublishWrites: writeMode === "publish_updates", + pushMode: + writeMode === "publish_updates" + ? "publish" + : writeMode === "stage_only" + ? "autosave" + : "none", + }, + } + : source; + return { + ...current, + source: patchSource(current.source), + sources: current.sources?.map((source) => patchSource(source)!), + }; +} + export function applyDocumentPropertyValueToDatabaseResponse( current: ContentDatabaseResponse | undefined, patch: { @@ -1218,6 +1269,29 @@ export function useSetContentDatabaseSourceWriteMode(documentId: string) { ContentDatabaseResponse, SetContentDatabaseSourceWriteModeRequest >("set-content-database-source-write-mode", { + onMutate: async (variables) => { + await queryClient.cancelQueries(contentDatabaseQueryFilter(documentId)); + const previous = queryClient.getQueriesData( + contentDatabaseQueryFilter(documentId), + ); + queryClient.setQueriesData( + contentDatabaseQueryFilter(documentId), + (current) => applyOptimisticBuilderWriteMode(current, variables), + ); + return { previous }; + }, + onError: (_error, _variables, context) => { + const rollback = context as + | { + previous?: Array< + [readonly unknown[], ContentDatabaseResponse | undefined] + >; + } + | undefined; + for (const [queryKey, data] of rollback?.previous ?? []) { + queryClient.setQueryData(queryKey, data); + } + }, onSuccess: (data) => { writeContentDatabaseResponseToCache(queryClient, documentId, data); queryClient.invalidateQueries({ From f34dccc0c0563c8e0a6e5baec7e85e3aa9efc1c0 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:48:32 -0400 Subject: [PATCH 30/51] fix(content): update Builder policy in click transaction --- .../editor/database/DatabaseView.tsx | 66 +++++++++++-------- .../content/app/hooks/use-content-database.ts | 23 ------- 2 files changed, 38 insertions(+), 51 deletions(-) diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 7521e6efe4..153a5704fe 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -159,6 +159,8 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { + applyOptimisticBuilderWriteMode, + contentDatabaseQueryFilter, isContentDatabaseUnavailable, useAddDatabaseItem, useAddContentDatabaseSourceFieldProperty, @@ -2935,36 +2937,44 @@ function DatabaseTable({ onReviewBuilderUpdate={(sourceId) => { openBuilderReview(sourceId); }} - onSetBuilderLiveWrites={(settings) => - setSourceWriteMode.mutate( - { - documentId: document.id, - sourceId: settings.sourceId, - writeMode: settings.writeMode, - allowPublicationTransitions: - settings.writeMode === "publish_updates" && - settings.allowPublicationTransitions === true, + onSetBuilderLiveWrites={(settings) => { + const request = { + documentId: document.id, + sourceId: settings.sourceId, + writeMode: settings.writeMode, + allowPublicationTransitions: + settings.writeMode === "publish_updates" && + settings.allowPublicationTransitions === true, + }; + const previous = queryClient.getQueriesData( + contentDatabaseQueryFilter(document.id), + ); + queryClient.setQueriesData( + contentDatabaseQueryFilter(document.id), + (current) => applyOptimisticBuilderWriteMode(current, request), + ); + setSourceWriteMode.mutate(request, { + onSuccess: () => { + toast.success(dbText("builderWriteModeUpdated"), { + description: + settings.writeMode === "publish_updates" + ? "Approved updates can write through to Builder while preserving publication state." + : settings.writeMode === "stage_only" + ? "Approved updates will stage Builder autosave revisions." + : "Builder writes are disabled for this source.", + }); }, - { - onSuccess: () => { - toast.success(dbText("builderWriteModeUpdated"), { - description: - settings.writeMode === "publish_updates" - ? "Approved updates can write through to Builder while preserving publication state." - : settings.writeMode === "stage_only" - ? "Approved updates will stage Builder autosave revisions." - : "Builder writes are disabled for this source.", - }); - }, - onError: (error) => { - toast.error(dbText("builderWriteModeWasNotChanged"), { - description: - error instanceof Error ? error.message : dbText("tryAgain"), - }); - }, + onError: (error) => { + for (const [queryKey, data] of previous) { + queryClient.setQueryData(queryKey, data); + } + toast.error(dbText("builderWriteModeWasNotChanged"), { + description: + error instanceof Error ? error.message : dbText("tryAgain"), + }); }, - ) - } + }); + }} sourceActionPending={ attachSource.isPending || changeSourceRole.isPending || diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index 4741b9e34f..c274f740cc 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -1269,29 +1269,6 @@ export function useSetContentDatabaseSourceWriteMode(documentId: string) { ContentDatabaseResponse, SetContentDatabaseSourceWriteModeRequest >("set-content-database-source-write-mode", { - onMutate: async (variables) => { - await queryClient.cancelQueries(contentDatabaseQueryFilter(documentId)); - const previous = queryClient.getQueriesData( - contentDatabaseQueryFilter(documentId), - ); - queryClient.setQueriesData( - contentDatabaseQueryFilter(documentId), - (current) => applyOptimisticBuilderWriteMode(current, variables), - ); - return { previous }; - }, - onError: (_error, _variables, context) => { - const rollback = context as - | { - previous?: Array< - [readonly unknown[], ContentDatabaseResponse | undefined] - >; - } - | undefined; - for (const [queryKey, data] of rollback?.previous ?? []) { - queryClient.setQueryData(queryKey, data); - } - }, onSuccess: (data) => { writeContentDatabaseResponseToCache(queryClient, documentId, data); queryClient.invalidateQueries({ From d6122fc9faee0da6004f898e663f345af925c17a Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:26:40 -0400 Subject: [PATCH 31/51] fix(content): preserve media draft sources --- .../editor/VisualEditor.markdown.test.ts | 53 +++++++++++++++++++ .../app/components/editor/VisualEditor.tsx | 42 ++++++++++++++- ...hile-collaborative-document-drafts-save.md | 6 +++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md diff --git a/templates/content/app/components/editor/VisualEditor.markdown.test.ts b/templates/content/app/components/editor/VisualEditor.markdown.test.ts index ab0da2a4e0..d45594cd98 100644 --- a/templates/content/app/components/editor/VisualEditor.markdown.test.ts +++ b/templates/content/app/components/editor/VisualEditor.markdown.test.ts @@ -34,6 +34,7 @@ import { shouldApplyExternalContentSync, shouldPersistEffectivelyEmptyEditorUpdate, shouldPersistLocalFileEditorUpdate, + shouldSkipMediaDraftPersistence, shouldSeedCollaborativeContent, VisualEditor, } from "./VisualEditor"; @@ -87,6 +88,58 @@ describe("placeholder ancestry", () => { }); }); +describe("media draft persistence", () => { + it("holds a selected empty media placeholder until it has a source", () => { + const editor = createFullEditor(); + + try { + editor.commands.setContent({ + type: "doc", + content: [{ type: "image", attrs: { src: null, alt: "" } }], + }); + editor.commands.setNodeSelection(0); + + expect(shouldSkipMediaDraftPersistence(editor)).toBe(true); + + editor.commands.updateAttributes("image", { + src: "https://cdn.example.com/diagram.png", + }); + expect(shouldSkipMediaDraftPersistence(editor)).toBe(false); + } finally { + editor.destroy(); + } + }); + + it("holds pending drop and paste uploads even when selection moved", () => { + const editor = createFullEditor(); + + try { + editor.commands.setContent({ + type: "doc", + content: [ + { + type: "video", + attrs: { src: null, uploadId: "video-upload-test" }, + }, + { type: "paragraph", content: [{ type: "text", text: "After" }] }, + ], + }); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + + expect(shouldSkipMediaDraftPersistence(editor)).toBe(true); + + editor.commands.setNodeSelection(0); + editor.commands.updateAttributes("video", { + src: "https://cdn.example.com/demo.mp4", + uploadId: null, + }); + expect(shouldSkipMediaDraftPersistence(editor)).toBe(false); + } finally { + editor.destroy(); + } + }); +}); + function triggerTextInput(editor: Editor, text: string) { const { from, to } = editor.state.selection; let handled = false; diff --git a/templates/content/app/components/editor/VisualEditor.tsx b/templates/content/app/components/editor/VisualEditor.tsx index c2f745fe82..8c7de4a335 100644 --- a/templates/content/app/components/editor/VisualEditor.tsx +++ b/templates/content/app/components/editor/VisualEditor.tsx @@ -29,7 +29,13 @@ import { TableRow } from "@tiptap/extension-table-row"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { Plugin, PluginKey, AllSelection, Selection } from "@tiptap/pm/state"; +import { + Plugin, + PluginKey, + AllSelection, + NodeSelection, + Selection, +} from "@tiptap/pm/state"; import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; import { useEditor, @@ -1000,6 +1006,39 @@ export function hasAncestorType( type MediaNodeType = "image" | "video" | "audio"; +const MEDIA_NODE_TYPES = new Set(["image", "video", "audio"]); + +/** + * Empty media nodes are transient editor UI, not durable document content. + * + * Persisting the placeholder before its async upload/link enrichment finishes + * lets the SQL echo reconcile the empty `src` back into the live Y.Doc. That + * can erase a successfully uploaded image or embedded video. Keep the local + * draft out of autosave until it has a source; uploads inserted by drop/paste + * are covered by their `uploadId` even when the selection is elsewhere. + */ +export function shouldSkipMediaDraftPersistence(editor: CoreEditor): boolean { + let hasPendingUpload = false; + editor.state.doc.descendants((node) => { + if ( + MEDIA_NODE_TYPES.has(node.type.name as MediaNodeType) && + Boolean(node.attrs.uploadId) + ) { + hasPendingUpload = true; + return false; + } + return !hasPendingUpload; + }); + if (hasPendingUpload) return true; + + const { selection } = editor.state; + if (!(selection instanceof NodeSelection)) return false; + return ( + MEDIA_NODE_TYPES.has(selection.node.type.name as MediaNodeType) && + !selection.node.attrs.src + ); +} + function mediaNodeLabel(typeName: MediaNodeType) { if (typeName === "image") return "Image"; if (typeName === "video") return "Video"; @@ -1972,6 +2011,7 @@ export function VisualEditor({ return; } if (isActiveSlashCommandDraft(editor)) return; + if (shouldSkipMediaDraftPersistence(editor)) return; persistEditorContent(editor, { userInitiated: transaction.getMeta(LOCAL_FILE_USER_EDIT_META) === true || diff --git a/templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md b/templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md new file mode 100644 index 0000000000..5b30fbed5e --- /dev/null +++ b/templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-24 +--- + +Uploaded and embedded media now stay intact while collaborative document drafts save. From d40af59473c0f4cfb82f1c39f8778a6397990dc6 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:44:54 -0400 Subject: [PATCH 32/51] fix(content): serialize collaborative document saves --- .../editor/DocumentEditor.layout.test.ts | 46 +++++++++++++++++++ .../app/components/editor/DocumentEditor.tsx | 36 ++++++++++++--- ...hile-collaborative-document-drafts-save.md | 2 +- 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index 9fcb0feca8..ff8f13c214 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -9,12 +9,58 @@ import { documentEditorDefaultIconKind, documentEditorDatabaseRegionClassName, documentEditorTitleRegionClassName, + enqueueDocumentSave, metadataUpdatesWithPendingTitle, titleMatchConfirmsSave, } from "./DocumentEditor"; import { compactToolbarBreadcrumbItems } from "./DocumentToolbar"; describe("document editor layout", () => { + it("serializes overlapping document saves without dropping the fuller snapshot", async () => { + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const queueRef = { current: Promise.resolve() }; + const events: string[] = []; + + const first = enqueueDocumentSave(queueRef, async () => { + events.push("first:start"); + await firstGate; + events.push("first:end"); + return "partial"; + }); + const second = enqueueDocumentSave(queueRef, async () => { + events.push("second:start"); + events.push("second:end"); + return "full"; + }); + + await Promise.resolve(); + expect(events).toEqual(["first:start"]); + releaseFirst(); + + await expect(first).resolves.toBe("partial"); + await expect(second).resolves.toBe("full"); + expect(events).toEqual([ + "first:start", + "first:end", + "second:start", + "second:end", + ]); + }); + + it("continues the save queue after an earlier request fails", async () => { + const queueRef = { current: Promise.resolve() }; + const first = enqueueDocumentSave(queueRef, async () => { + throw new Error("network interrupted"); + }); + const second = enqueueDocumentSave(queueRef, async () => "latest"); + + await expect(first).rejects.toThrow("network interrupted"); + await expect(second).resolves.toBe("latest"); + }); + it("flushes a pending title with an icon update", () => { expect( metadataUpdatesWithPendingTitle( diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 068fddd5e4..1927e6ab6d 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -261,6 +261,23 @@ type DocumentSaveResult = { contentPersisted: boolean; }; +export function enqueueDocumentSave( + queueRef: MutableRefObject>, + save: () => Promise, +): Promise { + // Content CAS assumes each local save starts from the result of the previous + // local save. Debounced typing and structural "save now" operations can + // otherwise overlap with the same baseUpdatedAt: the shorter request wins, + // and the later, fuller document is rejected as a conflict. Keep the safety + // guard and serialize this editor's writes instead of weakening CAS. + const queued = queueRef.current.then(save, save); + queueRef.current = queued.then( + () => undefined, + () => undefined, + ); + return queued; +} + function useMinViewportWidth(minWidth: number) { const [matches, setMatches] = useState(false); @@ -542,6 +559,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { const saveTimeoutRef = useRef | null>(null); const promotedBuilderBodyRef = useRef(null); const pendingDocumentSaveRef = useRef(null); + const documentSaveQueueRef = useRef>(Promise.resolve()); // Separate freshness watermarks for title and content so that a content save // never suppresses adopting a newer external title and vice versa. const lastSavedTitleRef = useRef<{ title: string; updatedAt: string | null }>( @@ -996,6 +1014,13 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { queryClient, ], ); + const queueDocumentSave = useCallback( + (title: string, content: string, options: DocumentSaveOptions = {}) => + enqueueDocumentSave(documentSaveQueueRef, () => + saveDocumentImmediately(title, content, options), + ), + [saveDocumentImmediately], + ); const flushPendingDocumentSave = useCallback( (pending: PendingDocumentSave) => { if (!pending.canEditWhenQueued) return; @@ -1014,7 +1039,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { const pending: PendingDocumentSave = { title, content, - save: saveDocumentImmediately, + save: queueDocumentSave, canEditWhenQueued: canEditRef.current, timeout: setTimeout(() => { if (pendingDocumentSaveRef.current === pending) { @@ -1027,7 +1052,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { pendingDocumentSaveRef.current = pending; saveTimeoutRef.current = pending.timeout; }, - [flushPendingDocumentSave, saveDocumentImmediately], + [flushPendingDocumentSave, queueDocumentSave], ); useEffect(() => { @@ -1324,13 +1349,10 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { } localContentRef.current = newContent; setLocalContent(newContent); - const result = await saveDocumentImmediately( - localTitleRef.current, - newContent, - ); + const result = await queueDocumentSave(localTitleRef.current, newContent); return result.contentPersisted; }, - [editorCanEdit, saveDocumentImmediately], + [editorCanEdit, queueDocumentSave], ); // Comments state — pending comment from text selection diff --git a/templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md b/templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md index 5b30fbed5e..b4de751374 100644 --- a/templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md +++ b/templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md @@ -3,4 +3,4 @@ type: fixed date: 2026-07-24 --- -Uploaded and embedded media now stay intact while collaborative document drafts save. +Rich document edits, including uploaded and embedded media, now stay intact while collaborative drafts save. From ae9c94733386dbbc7bb5dc3c4b4387107dc6521c Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:24:36 -0400 Subject: [PATCH 33/51] fix(content): persist structural slash commands --- .../editor/SlashCommandMenu.test.ts | 8 +++++ .../components/editor/SlashCommandMenu.tsx | 11 ++++++- .../editor/VisualEditor.markdown.test.ts | 31 ++++++++++++++++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/templates/content/app/components/editor/SlashCommandMenu.test.ts b/templates/content/app/components/editor/SlashCommandMenu.test.ts index b4f5dcf2bc..55b71ffab5 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.test.ts +++ b/templates/content/app/components/editor/SlashCommandMenu.test.ts @@ -76,6 +76,14 @@ describe("generate command affordances", () => { }); describe("slash command menu trigger", () => { + it("persists structural slash-command results immediately", () => { + const source = readSlashCommandMenuSource(); + + expect(source).toContain("const beforeDoc = editor.state.doc"); + expect(source).toContain("!editor.state.doc.eq(beforeDoc)"); + expect(source).toContain("await onDraftCommitted?.()"); + }); + it("opens for slash commands at the start of a block", () => { expect(parseSlashCommandQuery("/")).toBe(""); expect(parseSlashCommandQuery("/heading")).toBe("heading"); diff --git a/templates/content/app/components/editor/SlashCommandMenu.tsx b/templates/content/app/components/editor/SlashCommandMenu.tsx index 9fb13c40f6..8994e1e35b 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.tsx +++ b/templates/content/app/components/editor/SlashCommandMenu.tsx @@ -950,6 +950,7 @@ export function SlashCommandMenu({ const executeCommand = useCallback( async (cmd: CommandItem) => { if (editor.isDestroyed) return; + const beforeDoc = editor.state.doc; const slashRange = getActiveSlashCommandRange(editor) ?? (slashPosRef.current !== null @@ -963,8 +964,16 @@ export function SlashCommandMenu({ setQuery(""); slashPosRef.current = null; await cmd.action(editor, { slashRange }); + // Structural slash commands (especially an empty table) can be followed + // immediately by another modal command or navigation before the normal + // debounced onUpdate save settles. Persist the completed command now so + // the durable snapshot cannot omit the block. Media placeholders are + // still held by VisualEditor's pending-media guard until they have a src. + if (!editor.isDestroyed && !editor.state.doc.eq(beforeDoc)) { + await onDraftCommitted?.(); + } }, - [editor], + [editor, onDraftCommitted], ); useEffect(() => { diff --git a/templates/content/app/components/editor/VisualEditor.markdown.test.ts b/templates/content/app/components/editor/VisualEditor.markdown.test.ts index d45594cd98..172801592b 100644 --- a/templates/content/app/components/editor/VisualEditor.markdown.test.ts +++ b/templates/content/app/components/editor/VisualEditor.markdown.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { docToNfm } from "@shared/nfm"; +import { docToNfm, nfmToDoc } from "@shared/nfm"; import { VISUAL_INDENT, parseNfmForEditor, @@ -301,6 +301,35 @@ describe("VisualEditor markdown round-tripping", () => { } }); + it("round-trips a newly inserted empty table through canonical NFM", () => { + const editor = createFullEditor(); + + try { + expect( + editor.commands.insertTable({ + rows: 3, + cols: 3, + withHeaderRow: false, + }), + ).toBe(true); + const markdown = docToNfm(editor.getJSON() as any); + + expect(markdown).toContain(""); + expect(markdown.match(//g)).toHaveLength(3); + expect(markdown.match(/
<\/td>/g)).toHaveLength(9); + + const restored = nfmToDoc(markdown); + const table = restored.content.find((node) => node.type === "table"); + expect(table).toBeDefined(); + expect(table?.content).toHaveLength(3); + expect(table?.content?.flatMap((row) => row.content ?? [])).toHaveLength( + 9, + ); + } finally { + editor.destroy(); + } + }); + it("normalizes table header cells to the first row and first column only", async () => { const editor = new Editor({ extensions: createVisualEditorExtensions(), From 7a11944382007f9cfc61c6bcc2e4f78bc9fc94f8 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:56:32 -0400 Subject: [PATCH 34/51] fix(content): guard structural media drafts --- .../components/editor/SlashCommandMenu.tsx | 30 +++++----- .../editor/VisualEditor.markdown.test.ts | 59 +++++++++++++++++++ .../app/components/editor/VisualEditor.tsx | 23 ++++++-- 3 files changed, 91 insertions(+), 21 deletions(-) diff --git a/templates/content/app/components/editor/SlashCommandMenu.tsx b/templates/content/app/components/editor/SlashCommandMenu.tsx index 8994e1e35b..d4915ad956 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.tsx +++ b/templates/content/app/components/editor/SlashCommandMenu.tsx @@ -56,7 +56,7 @@ import { buildRegistrySlashItems } from "./registrySlashItems"; interface SlashCommandMenuProps { editor: Editor; documentId?: string; - onDraftCommitted?: () => void | Promise; + onDraftCommitted?: () => boolean | void | Promise; onDraftPersisted?: (markdown: string) => boolean | Promise; /** * The open document's linked Notion page id, when it has one. When set, the @@ -130,6 +130,16 @@ export interface CommandItem { ) => void | boolean | Promise; } +export type MediaPlaceholderType = "image" | "video" | "audio"; + +export function insertMediaPlaceholder( + editor: Editor, + type: MediaPlaceholderType, +) { + const attrs = type === "image" ? { src: null, alt: "" } : { src: null }; + return editor.chain().focus().insertContent({ type, attrs }).run(); +} + function getActiveSlashCommandRange(editor: Editor) { const { state } = editor; if (!state.selection.empty) return null; @@ -648,11 +658,7 @@ export function SlashCommandMenu({ description: t("editor.slash.imageDescription"), icon: IconPhoto, action: (editor) => { - editor - .chain() - .focus() - .insertContent({ type: "image", attrs: { src: null, alt: "" } }) - .run(); + insertMediaPlaceholder(editor, "image"); }, }; @@ -661,11 +667,7 @@ export function SlashCommandMenu({ description: t("editor.slash.videoDescription"), icon: IconVideo, action: (editor) => { - editor - .chain() - .focus() - .insertContent({ type: "video", attrs: { src: null } }) - .run(); + insertMediaPlaceholder(editor, "video"); }, }; @@ -674,11 +676,7 @@ export function SlashCommandMenu({ description: t("editor.slash.audioDescription"), icon: IconMusic, action: (editor) => { - editor - .chain() - .focus() - .insertContent({ type: "audio", attrs: { src: null } }) - .run(); + insertMediaPlaceholder(editor, "audio"); }, }; diff --git a/templates/content/app/components/editor/VisualEditor.markdown.test.ts b/templates/content/app/components/editor/VisualEditor.markdown.test.ts index 172801592b..85805c6571 100644 --- a/templates/content/app/components/editor/VisualEditor.markdown.test.ts +++ b/templates/content/app/components/editor/VisualEditor.markdown.test.ts @@ -22,6 +22,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { CodeBlock } from "./extensions/CodeBlockNode"; import { NotionToggle } from "./extensions/NotionExtensions"; import { createPreviewDocumentSaveController } from "./previewDocumentSaveController"; +import { insertMediaPlaceholder } from "./SlashCommandMenu"; import { createVisualEditorExtensions, EmptyLineParagraph, @@ -36,6 +37,7 @@ import { shouldPersistLocalFileEditorUpdate, shouldSkipMediaDraftPersistence, shouldSeedCollaborativeContent, + serializeEditorDraftForPersistence, VisualEditor, } from "./VisualEditor"; @@ -89,6 +91,59 @@ describe("placeholder ancestry", () => { }); describe("media draft persistence", () => { + it.each([ + ["image", "https://cdn.example.com/birds.png"], + ["video", "https://cdn.example.com/flower.mp4"], + ] as const)( + "suppresses the slash %s placeholder commit until its source is set", + (type, src) => { + const editor = createFullEditor(); + const persisted: string[] = []; + const querySelectorAll = Element.prototype.querySelectorAll; + const querySelectorAllSpy = vi + .spyOn(Element.prototype, "querySelectorAll") + .mockImplementation(function (this: Element, selector: string) { + try { + return querySelectorAll.call(this, selector); + } catch { + const matches = selector.split(",").flatMap((part) => { + try { + return Array.from(querySelectorAll.call(this, part)); + } catch { + return []; + } + }); + return matches as unknown as NodeListOf; + } + }); + const persistDraft = () => { + const draft = serializeEditorDraftForPersistence(editor); + if (draft !== null) persisted.push(draft); + }; + + try { + document.body.appendChild(editor.view.dom); + editor.commands.insertContent("/"); + editor.commands.setTextSelection(2); + editor.commands.deleteRange({ from: 1, to: 2 }); + insertMediaPlaceholder(editor, type); + + persistDraft(); + expect(persisted).toEqual([]); + + editor.commands.updateAttributes(type, { src }); + persistDraft(); + + expect(persisted).toHaveLength(1); + expect(persisted[0]).toContain(src); + } finally { + querySelectorAllSpy.mockRestore(); + editor.view.dom.remove(); + editor.destroy(); + } + }, + ); + it("holds a selected empty media placeholder until it has a source", () => { const editor = createFullEditor(); @@ -100,11 +155,15 @@ describe("media draft persistence", () => { editor.commands.setNodeSelection(0); expect(shouldSkipMediaDraftPersistence(editor)).toBe(true); + expect(serializeEditorDraftForPersistence(editor)).toBeNull(); editor.commands.updateAttributes("image", { src: "https://cdn.example.com/diagram.png", }); expect(shouldSkipMediaDraftPersistence(editor)).toBe(false); + expect(serializeEditorDraftForPersistence(editor)).toBe( + "![](https://cdn.example.com/diagram.png)", + ); } finally { editor.destroy(); } diff --git a/templates/content/app/components/editor/VisualEditor.tsx b/templates/content/app/components/editor/VisualEditor.tsx index 8c7de4a335..2179c555b7 100644 --- a/templates/content/app/components/editor/VisualEditor.tsx +++ b/templates/content/app/components/editor/VisualEditor.tsx @@ -1039,6 +1039,18 @@ export function shouldSkipMediaDraftPersistence(editor: CoreEditor): boolean { ); } +/** + * Serialize only complete editor drafts. Structural slash commands explicitly + * ask to persist after their transaction, so this guard must live in the shared + * persistence path rather than only in `onUpdate`. + */ +export function serializeEditorDraftForPersistence( + editor: CoreEditor, +): string | null { + if (shouldSkipMediaDraftPersistence(editor)) return null; + return docToNfm(editor.getJSON() as any); +} + function mediaNodeLabel(typeName: MediaNodeType) { if (typeName === "image") return "Image"; if (typeName === "video") return "Video"; @@ -1821,8 +1833,9 @@ export function VisualEditor({ const guards = guardsRef.current; if (!guards) return false; try { - const normalized = - options?.markdown ?? docToNfm(editorToPersist.getJSON() as any); + const serialized = serializeEditorDraftForPersistence(editorToPersist); + if (serialized === null) return true; + const normalized = options?.markdown ?? serialized; if (localFileMode && normalized === content) return true; // TipTap/Yjs can emit a local-looking empty-paragraph transaction while // an editor is mounting or reconciling. Content serializes that filler @@ -2332,9 +2345,9 @@ export function VisualEditor({ editor={editor} documentId={documentId} notionPageId={notionPageId} - onDraftCommitted={() => { - void persistEditorContent(editor, { userInitiated: true }); - }} + onDraftCommitted={() => + persistEditorContent(editor, { userInitiated: true }) + } onDraftPersisted={(markdown) => persistEditorContent(editor, { markdown, From 9f4e8fa5e882438bfc50717887cc39c4469bfa35 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:07:58 -0400 Subject: [PATCH 35/51] fix(content): persist committed media sources --- .../editor/VisualEditor.markdown.test.ts | 34 +++++++++ .../app/components/editor/VisualEditor.tsx | 69 +++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/templates/content/app/components/editor/VisualEditor.markdown.test.ts b/templates/content/app/components/editor/VisualEditor.markdown.test.ts index 85805c6571..1ee7260118 100644 --- a/templates/content/app/components/editor/VisualEditor.markdown.test.ts +++ b/templates/content/app/components/editor/VisualEditor.markdown.test.ts @@ -8,6 +8,7 @@ import { } from "@shared/notion-markdown"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Editor, getSchema } from "@tiptap/core"; +import type { Transaction } from "@tiptap/pm/state"; import StarterKit from "@tiptap/starter-kit"; import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; @@ -25,6 +26,7 @@ import { createPreviewDocumentSaveController } from "./previewDocumentSaveContro import { insertMediaPlaceholder } from "./SlashCommandMenu"; import { createVisualEditorExtensions, + didCommitMediaSource, EmptyLineParagraph, getRecentEditPresenceMarkerRect, hasAncestorType, @@ -91,6 +93,38 @@ describe("placeholder ancestry", () => { }); describe("media draft persistence", () => { + it("detects a media source enrichment but not unrelated media movement", () => { + const editor = createFullEditor(); + const transactions: Transaction[] = []; + editor.on("transaction", ({ transaction }) => + transactions.push(transaction), + ); + + try { + editor.commands.setContent({ + type: "doc", + content: [{ type: "video", attrs: { src: null } }], + }); + editor.commands.setNodeSelection(0); + transactions.length = 0; + + editor.commands.updateAttributes("video", { + src: "https://cdn.example.com/flower.mp4", + }); + expect(transactions.some(didCommitMediaSource)).toBe(true); + + transactions.length = 0; + const paragraph = editor.schema.nodes.paragraph.create( + null, + editor.schema.text("Before media"), + ); + editor.view.dispatch(editor.state.tr.insert(0, paragraph)); + expect(transactions.some(didCommitMediaSource)).toBe(false); + } finally { + editor.destroy(); + } + }); + it.each([ ["image", "https://cdn.example.com/birds.png"], ["video", "https://cdn.example.com/flower.mp4"], diff --git a/templates/content/app/components/editor/VisualEditor.tsx b/templates/content/app/components/editor/VisualEditor.tsx index 2179c555b7..ea0c1a156b 100644 --- a/templates/content/app/components/editor/VisualEditor.tsx +++ b/templates/content/app/components/editor/VisualEditor.tsx @@ -35,6 +35,7 @@ import { AllSelection, NodeSelection, Selection, + type Transaction, } from "@tiptap/pm/state"; import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; import { @@ -981,6 +982,10 @@ interface VisualEditorExtensionOptions { localFilePath?: string | null; referenceDepth?: number; emptyBlockPlaceholder?: string; + onMediaSourceCommitted?: ( + editor: CoreEditor, + transaction: Transaction, + ) => void; } export function hasAncestorType( @@ -1008,6 +1013,43 @@ type MediaNodeType = "image" | "video" | "audio"; const MEDIA_NODE_TYPES = new Set(["image", "video", "audio"]); +function mediaSourceCounts(doc: ProseMirrorNode) { + const counts = new Map(); + doc.descendants((node) => { + if (!MEDIA_NODE_TYPES.has(node.type.name as MediaNodeType)) return true; + const src = typeof node.attrs.src === "string" ? node.attrs.src : ""; + if (!src) return false; + const key = `${node.type.name}\u0000${src}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + return false; + }); + return counts; +} + +export function didCommitMediaSource(transaction: Transaction): boolean { + if (!transaction.docChanged) return false; + const before = mediaSourceCounts(transaction.before); + const after = mediaSourceCounts(transaction.doc); + return [...after].some(([key, count]) => count > (before.get(key) ?? 0)); +} + +const MediaSourceCommit = Extension.create<{ + onMediaSourceCommitted?: ( + editor: CoreEditor, + transaction: Transaction, + ) => void; +}>({ + name: "mediaSourceCommit", + addOptions() { + return { onMediaSourceCommitted: undefined }; + }, + onTransaction({ editor, transaction }) { + if (didCommitMediaSource(transaction)) { + this.options.onMediaSourceCommitted?.(editor, transaction); + } + }, +}); + /** * Empty media nodes are transient editor UI, not durable document content. * @@ -1354,6 +1396,7 @@ export function createVisualEditorExtensions({ localFilePath, referenceDepth = 0, emptyBlockPlaceholder = DEFAULT_EMPTY_BLOCK_PLACEHOLDER, + onMediaSourceCommitted, }: VisualEditorExtensionOptions = {}): Extensions { // Build on the SHARED editor core (StarterKit base + the Collaboration / // CollaborationCaret wiring + collab undo/redo gating + ordering), then inject @@ -1421,6 +1464,7 @@ export function createVisualEditorExtensions({ documentId, onAudioComment: onImageComment, }), + MediaSourceCommit.configure({ onMediaSourceCommitted }), CustomTable.configure({ resizable: false, HTMLAttributes: { class: "notion-table" }, @@ -1737,6 +1781,15 @@ export function VisualEditor({ onSaveContentRef.current = onSaveContent; const notionPageLinksRef = useRef(notionPageLinks); notionPageLinksRef.current = notionPageLinks; + const onMediaSourceCommittedRef = useRef< + ((editor: CoreEditor, transaction: Transaction) => void) | null + >(null); + const onMediaSourceCommitted = useCallback( + (editor: CoreEditor, transaction: Transaction) => { + onMediaSourceCommittedRef.current?.(editor, transaction); + }, + [], + ); const resolveNotionPageLink = useCallback((notionPageId: string) => { const normalized = notionPageId.replace(/-/g, "").toLowerCase(); return ( @@ -1794,6 +1847,7 @@ export function VisualEditor({ localFilePath, referenceDepth, emptyBlockPlaceholder: t("editor.emptyBlockPlaceholder"), + onMediaSourceCommitted, }), [ documentId, @@ -1809,6 +1863,7 @@ export function VisualEditor({ localFilePath, referenceDepth, t, + onMediaSourceCommitted, ], ); @@ -1870,6 +1925,20 @@ export function VisualEditor({ }, [content, localFileMode, t], ); + onMediaSourceCommittedRef.current = async (editorToPersist, transaction) => { + const guards = guardsRef.current; + if (!guards || guards.shouldIgnoreUpdate(transaction)) return; + try { + await persistEditorContent(editorToPersist, { + immediate: true, + userInitiated: true, + }); + } catch (error) { + // The ordinary onUpdate path still queues its debounced retry. Keep the + // immediate durability attempt from becoming an unhandled rejection. + console.error("Media source persistence error:", error); + } + }; const editor = useEditor({ extensions, From 9716070804820d66c64ac9026a4aa7109968f41a Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:38:34 -0400 Subject: [PATCH 36/51] fix(content): persist rich drafts after metadata updates --- .../editor/DocumentEditor.layout.test.ts | 32 +++++++++++++++++++ .../app/components/editor/DocumentEditor.tsx | 29 +++++++++++++++++ .../editor/SlashCommandMenu.test.ts | 1 + .../components/editor/SlashCommandMenu.tsx | 7 ++-- .../app/components/editor/VisualEditor.tsx | 7 ++-- 5 files changed, 72 insertions(+), 4 deletions(-) diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index ff8f13c214..ffc801302c 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -11,6 +11,7 @@ import { documentEditorTitleRegionClassName, enqueueDocumentSave, metadataUpdatesWithPendingTitle, + refreshUnchangedContentSaveWatermark, titleMatchConfirmsSave, } from "./DocumentEditor"; import { compactToolbarBreadcrumbItems } from "./DocumentToolbar"; @@ -61,6 +62,37 @@ describe("document editor layout", () => { await expect(second).resolves.toBe("latest"); }); + it("advances the content CAS base across metadata-only row updates", () => { + expect( + refreshUnchangedContentSaveWatermark({ + serverContent: "saved prefix", + serverUpdatedAt: "2026-07-24T17:00:02.000Z", + lastSaved: { + content: "saved prefix", + updatedAt: "2026-07-24T17:00:01.000Z", + }, + }), + ).toEqual({ + content: "saved prefix", + updatedAt: "2026-07-24T17:00:02.000Z", + }); + }); + + it("does not advance the content CAS base across a real body change", () => { + const lastSaved = { + content: "saved prefix", + updatedAt: "2026-07-24T17:00:01.000Z", + }; + + expect( + refreshUnchangedContentSaveWatermark({ + serverContent: "external body", + serverUpdatedAt: "2026-07-24T17:00:02.000Z", + lastSaved, + }), + ).toBe(lastSaved); + }); + it("flushes a pending title with an icon update", () => { expect( metadataUpdatesWithPendingTitle( diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 1927e6ab6d..296341a01e 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -144,6 +144,28 @@ export function titleMatchConfirmsSave(args: { ); } +export function refreshUnchangedContentSaveWatermark(args: { + serverContent: string; + serverUpdatedAt: string | null; + lastSaved: ContentSaveWatermark; +}): ContentSaveWatermark { + if ( + args.serverContent !== args.lastSaved.content || + !args.serverUpdatedAt || + (args.lastSaved.updatedAt && + args.serverUpdatedAt <= args.lastSaved.updatedAt) + ) { + return args.lastSaved; + } + + // documents.updatedAt versions the whole row, not just the body. If the + // fetched body still byte-matches our saved baseline, a newer timestamp can + // only describe a title/icon/metadata update. Advance the content CAS base so + // a local rich-text tail is not silently preflight-dropped. A concurrent body + // edit still differs here and remains protected by the server CAS. + return { ...args.lastSaved, updatedAt: args.serverUpdatedAt }; +} + function adoptConfirmedSaveWatermarks({ saved, savedAt, @@ -580,6 +602,8 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { document.updatedAt ?? null, ); documentUpdatedAtRef.current = document.updatedAt ?? null; + const documentContentRef = useRef(document.content); + documentContentRef.current = document.content; const handleBackgroundSaveError = useCallback( (error: unknown) => { toast.error(t("empty.genericError"), { @@ -925,6 +949,11 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { content: string, options: DocumentSaveOptions = {}, ): Promise => { + lastSavedContentRef.current = refreshUnchangedContentSaveWatermark({ + serverContent: documentContentRef.current, + serverUpdatedAt: documentUpdatedAtRef.current, + lastSaved: lastSavedContentRef.current, + }); // Never clobber a newer server version (e.g. an agent edit we haven't // reconciled into the editor yet) with the editor's current — possibly // stale — content. Guard per-field using the field's own watermark. diff --git a/templates/content/app/components/editor/SlashCommandMenu.test.ts b/templates/content/app/components/editor/SlashCommandMenu.test.ts index 55b71ffab5..536b0cb269 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.test.ts +++ b/templates/content/app/components/editor/SlashCommandMenu.test.ts @@ -82,6 +82,7 @@ describe("slash command menu trigger", () => { expect(source).toContain("const beforeDoc = editor.state.doc"); expect(source).toContain("!editor.state.doc.eq(beforeDoc)"); expect(source).toContain("await onDraftCommitted?.()"); + expect(source).toContain("persisted === false"); }); it("opens for slash commands at the start of a block", () => { diff --git a/templates/content/app/components/editor/SlashCommandMenu.tsx b/templates/content/app/components/editor/SlashCommandMenu.tsx index d4915ad956..8d6a8b02d3 100644 --- a/templates/content/app/components/editor/SlashCommandMenu.tsx +++ b/templates/content/app/components/editor/SlashCommandMenu.tsx @@ -968,10 +968,13 @@ export function SlashCommandMenu({ // the durable snapshot cannot omit the block. Media placeholders are // still held by VisualEditor's pending-media guard until they have a src. if (!editor.isDestroyed && !editor.state.doc.eq(beforeDoc)) { - await onDraftCommitted?.(); + const persisted = await onDraftCommitted?.(); + if (persisted === false) { + toast.error(t("empty.genericError")); + } } }, - [editor, onDraftCommitted], + [editor, onDraftCommitted, t], ); useEffect(() => { diff --git a/templates/content/app/components/editor/VisualEditor.tsx b/templates/content/app/components/editor/VisualEditor.tsx index ea0c1a156b..04e99345cb 100644 --- a/templates/content/app/components/editor/VisualEditor.tsx +++ b/templates/content/app/components/editor/VisualEditor.tsx @@ -1929,13 +1929,16 @@ export function VisualEditor({ const guards = guardsRef.current; if (!guards || guards.shouldIgnoreUpdate(transaction)) return; try { - await persistEditorContent(editorToPersist, { + const persisted = await persistEditorContent(editorToPersist, { immediate: true, userInitiated: true, }); + if (!persisted) throw new Error(t("empty.genericError")); } catch (error) { // The ordinary onUpdate path still queues its debounced retry. Keep the - // immediate durability attempt from becoming an unhandled rejection. + // immediate durability attempt from becoming an unhandled rejection, + // but fail visibly instead of treating a skipped save as success. + toast.error(t("empty.genericError")); console.error("Media source persistence error:", error); } }; From bb920f079c76e681c92cffd9baa8caa05695c441 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:05:31 -0400 Subject: [PATCH 37/51] fix(content): scope Builder review snapshots --- .../actions/_database-source-utils.test.ts | 24 ++++++++++++++++ .../content/actions/_database-source-utils.ts | 28 +++++++++++++++++++ .../content/app/hooks/use-content-database.ts | 7 ++++- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/templates/content/actions/_database-source-utils.test.ts b/templates/content/actions/_database-source-utils.test.ts index 61857a0d0f..1c95b7aff6 100644 --- a/templates/content/actions/_database-source-utils.test.ts +++ b/templates/content/actions/_database-source-utils.test.ts @@ -25,6 +25,7 @@ import { builderBodyHydrationAttemptIsTerminal, builderBodyNeedsSourceComponentWrite, builderReviewBodyCandidateDocumentIds, + knownBuilderReviewDocumentIds, builderSourcePropertyAssignments, builderBodyHydrationVersion, builderBodyUnavailableVersion, @@ -1051,6 +1052,29 @@ describe("database source helpers", () => { ).toEqual(["changed", "media-reconversion", "fixture"]); }); + it("scopes a bounded known review batch before broad body discovery", () => { + expect( + knownBuilderReviewDocumentIds( + [ + { documentId: "doc-new" }, + { documentId: "doc-new" }, + { documentId: "doc-existing" }, + ], + 100, + ), + ).toEqual(["doc-new", "doc-existing"]); + expect(knownBuilderReviewDocumentIds([], 100)).toBeNull(); + expect( + knownBuilderReviewDocumentIds([{ documentId: null }], 100), + ).toBeNull(); + expect( + knownBuilderReviewDocumentIds( + [{ documentId: "doc-1" }, { documentId: "doc-2" }], + 1, + ), + ).toBeNull(); + }); + it("detects local Builder body edits as outbound pending changes", () => { const [changeSet] = buildBuilderLocalOutboundChangeSets({ source: { sourceType: "builder-cms" }, diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 1583b48de7..60a485dfad 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -3254,6 +3254,19 @@ export function builderReviewBodyCandidateDocumentIds( }); } +export function knownBuilderReviewDocumentIds( + changeSets: Array<{ documentId: string | null }>, + limit: number, +): string[] | null { + if (changeSets.length === 0 || changeSets.length > limit) return null; + const documentIds = new Set(); + for (const changeSet of changeSets) { + if (!changeSet.documentId) return null; + documentIds.add(changeSet.documentId); + } + return [...documentIds]; +} + type BuilderReviewSourceValueTextKey = | typeof BUILDER_CMS_BODY_BLOCKS_HASH_KEY | typeof BUILDER_CMS_BODY_CONTENT_KEY; @@ -3359,6 +3372,21 @@ export async function getContentDatabaseSourceSnapshotForReview( changeSet.state === "staged_revision" || changeSet.state === "approved"), ); + // The interactive review surface is capped at 100 rows. When a complete + // pending batch is already known, load heavy body/sidecar data only for those + // documents instead of first transferring every hydrated Builder article to + // rediscover body-only candidates. That broad discovery remains the fallback + // when no pending batch exists (and after the current batch is resolved). + const knownReviewDocumentIds = knownBuilderReviewDocumentIds( + reviewableChanges, + 100, + ); + if (knownReviewDocumentIds) { + return loadSourceSnapshot(source, database, { + includeHeavyBuilderBodyValues: true, + documentIds: knownReviewDocumentIds, + }); + } if (reviewableChanges.some((changeSet) => !changeSet.documentId)) { return loadSourceSnapshot(source, database, { includeHeavyBuilderBodyValues: true, diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index c274f740cc..f1f141597f 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -730,7 +730,12 @@ export function useAddDatabaseItem(documentId: string) { return useActionMutation( "add-database-item", { - onSuccess: () => { + onSuccess: (data) => { + // The action returns the committed row and full database snapshot. + // Seed every active pagination key before invalidating so navigating + // away from the creation side-peek cannot briefly lose an appended row + // behind an older 100/200-row response. + writeContentDatabaseResponseToCache(queryClient, documentId, data); queryClient.invalidateQueries({ queryKey: contentDatabaseQueryKey(documentId), }); From dd39ec6496e4d0218add1bdf8b8390014191f24e Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:17:51 -0400 Subject: [PATCH 38/51] fix(content): convert callout editor sentinels --- templates/content/shared/builder-mdx.test.ts | 60 +++++++++++++ templates/content/shared/builder-mdx.ts | 89 ++++++++++++++++---- 2 files changed, 133 insertions(+), 16 deletions(-) diff --git a/templates/content/shared/builder-mdx.test.ts b/templates/content/shared/builder-mdx.test.ts index dead0ebd92..d313591f8e 100644 --- a/templates/content/shared/builder-mdx.test.ts +++ b/templates/content/shared/builder-mdx.test.ts @@ -536,6 +536,52 @@ describe("Builder MDX conversion", () => { ]); }); + it("omits the editor's trailing empty paragraph inside a callout", async () => { + const blocks = await builderMdxBodyToBuilderBlocks( + [ + '', + "\tLark callout", + "\t", + "", + ].join("\n"), + {}, + ); + + expect(blocks).toMatchObject([ + { + component: { + name: "Text", + options: { + text: "

💡

Lark callout

", + }, + }, + }, + ]); + expect(JSON.stringify(blocks)).not.toContain("empty-block"); + }); + + it("preserves an empty-block literal in fenced code inside a callout", async () => { + const blocks = await builderMdxBodyToBuilderBlocks( + ["", "\t```mdx", "\t", "\t```", ""].join( + "\n", + ), + {}, + ); + + expect(JSON.stringify(blocks)).toContain("empty-block"); + }); + + it("preserves an empty-block literal in inline code inside a callout", async () => { + const blocks = await builderMdxBodyToBuilderBlocks( + ["", "\tUse `` literally.", ""].join( + "\n", + ), + {}, + ); + + expect(JSON.stringify(blocks)).toContain("empty-block"); + }); + it("renders unsafe callout links as text without a stored script URL", async () => { const blocks = await builderMdxBodyToBuilderBlocks( [ @@ -617,6 +663,20 @@ describe("Builder MDX conversion", () => { {}, ), ).rejects.toThrow("Unsupported dynamic syntax inside Builder callout"); + await expect( + builderMdxBodyToBuilderBlocks( + ["", '\t', ""].join( + "\n", + ), + {}, + ), + ).rejects.toThrow("Unsupported Builder callout empty-block syntax"); + await expect( + builderMdxBodyToBuilderBlocks( + ["", "\t", ""].join("\n"), + {}, + ), + ).rejects.toThrow("Unsupported Builder callout empty-block syntax"); }); it("preserves ordered and unordered Markdown lists as distinct Builder HTML lists", async () => { diff --git a/templates/content/shared/builder-mdx.ts b/templates/content/shared/builder-mdx.ts index 7025779902..789a223a6f 100644 --- a/templates/content/shared/builder-mdx.ts +++ b/templates/content/shared/builder-mdx.ts @@ -1872,19 +1872,75 @@ function freshTextHtmlBlock(text: string, stableSource: string): unknown { }; } -function assertSafeFreshCalloutNode(node: MdxNode) { - for (const child of node.children ?? []) { - if ( - child.type === "mdxFlowExpression" || - child.type === "mdxTextExpression" || - child.type === "mdxjsEsm" || - child.type === "mdxJsxFlowElement" || - child.type === "mdxJsxTextElement" - ) { - throw new Error("Unsupported dynamic syntax inside Builder callout."); +function assertSafeFreshCalloutNode(node: MdxNode, raw: string) { + const rootStart = node.position?.start?.offset; + const visit = (candidate: MdxNode) => { + for (const child of candidate.children ?? []) { + const childStart = child.position?.start?.offset; + const childEnd = child.position?.end?.offset; + const childRaw = + typeof rootStart === "number" && + typeof childStart === "number" && + typeof childEnd === "number" + ? raw.slice(childStart - rootStart, childEnd - rootStart).trim() + : ""; + const emptyBlockNode = + (child.type === "mdxJsxFlowElement" || + child.type === "mdxJsxTextElement") && + child.name === "empty-block"; + if (emptyBlockNode) { + if (isEmptyEditorBlockSentinel(child, childRaw)) continue; + throw new Error( + "Unsupported Builder callout empty-block syntax. Only an attribute-free, self-closing Content editor sentinel can be omitted.", + ); + } + if ( + child.type === "mdxFlowExpression" || + child.type === "mdxTextExpression" || + child.type === "mdxjsEsm" || + child.type === "mdxJsxFlowElement" || + child.type === "mdxJsxTextElement" + ) { + throw new Error("Unsupported dynamic syntax inside Builder callout."); + } + visit(child); } - assertSafeFreshCalloutNode(child); - } + }; + visit(node); +} + +function removeSafeCalloutEmptyBlockSentinels(node: MdxNode, raw: string) { + const rootStart = node.position?.start?.offset; + if (typeof rootStart !== "number") return raw; + const ranges: Array<{ start: number; end: number }> = []; + + const visit = (candidate: MdxNode) => { + for (const child of candidate.children ?? []) { + const start = child.position?.start?.offset; + const end = child.position?.end?.offset; + if (typeof start !== "number" || typeof end !== "number") { + visit(child); + continue; + } + const relativeStart = start - rootStart; + const relativeEnd = end - rootStart; + const childRaw = raw.slice(relativeStart, relativeEnd).trim(); + if (isEmptyEditorBlockSentinel(child, childRaw)) { + ranges.push({ start: relativeStart, end: relativeEnd }); + continue; + } + visit(child); + } + }; + visit(node); + + return ranges + .sort((left, right) => right.start - left.start) + .reduce( + (current, range) => + `${current.slice(0, range.start)}${current.slice(range.end)}`, + raw, + ); } function findMdxOpeningTagEnd(raw: string) { @@ -1936,14 +1992,15 @@ function freshCalloutBlock( `Unsupported Builder callout attribute: ${attribute.name}.`, ); } - assertSafeFreshCalloutNode(node); + assertSafeFreshCalloutNode(node, raw); - const openingEnd = findMdxOpeningTagEnd(raw); - const closingStart = raw.lastIndexOf(""); + const contentRaw = removeSafeCalloutEmptyBlockSentinels(node, raw); + const openingEnd = findMdxOpeningTagEnd(contentRaw); + const closingStart = contentRaw.lastIndexOf(""); if (openingEnd < 0 || closingStart < openingEnd) { throw new Error("Builder callout must have an explicit closing tag."); } - const markdown = raw + const markdown = contentRaw .slice(openingEnd + 1, closingStart) .split(/\r?\n/) .map((line) => line.replace(/^(?:\t| {2})/, "")) From 70793caee6aac8cf334149d70e65cb307980eb0c Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:06:39 -0400 Subject: [PATCH 39/51] fix(content): republish approved Builder updates --- .../execute-builder-source-execution.test.ts | 55 ++++++++++++++++--- .../execute-builder-source-execution.ts | 8 ++- ...sh-approved-updates-to-entries-that-are.md | 6 ++ 3 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 templates/content/changelog/2026-07-24-builder-can-now-publish-approved-updates-to-entries-that-are.md diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index 0719364bdb..691773999e 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -682,7 +682,7 @@ describe("execute Builder source execution", () => { }); }); - it("blocks publish transitions when the entry is already published", async () => { + it("publishes an approved revision when the entry is already published", async () => { const approvedChangeSet = changeSet({ pushMode: "draft" }); const builderSource = source({ changeSets: [approvedChangeSet], @@ -717,15 +717,56 @@ describe("execute Builder source execution", () => { }, deps, ), - ).rejects.toThrow("Entry is already published."); + ).resolves.toMatchObject(RESPONSE); - expect(deps.updateExecutionState).toHaveBeenCalledWith( - expect.objectContaining({ - executionId: execution.id, - state: "blocked", - lastError: "Entry is already published.", + expect(deps.readLiveEntry).toHaveBeenCalledTimes(1); + expect(deps.executeWrite).toHaveBeenCalledWith({ + request: expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ title: "New title" }), + published: "published", + }), }), + }); + }); + + it("fails closed when a publish preflight cannot verify publication state", async () => { + const approvedChangeSet = changeSet({ pushMode: "draft" }); + const builderSource = source({ + changeSets: [approvedChangeSet], + metadata: { allowPublicationTransitions: true }, + }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + publicationTransition: "publish", + }); + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: { + exists: true, + published: null, + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + }, + }); + + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "draft", + publicationTransition: "publish", + }, + deps, + ), + ).rejects.toThrow( + "Builder publication state could not be verified; refresh and re-review.", ); + expect(deps.claimExecution).not.toHaveBeenCalled(); expect(deps.executeWrite).not.toHaveBeenCalled(); }); diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index 883982d343..e86c34fabd 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -494,8 +494,12 @@ function livePreflightBlockMessage(args: { ) { return "Builder body changed since this diff was approved; refresh and re-review."; } - if (args.effect === "publish" && args.liveState.published !== "draft") { - return "Entry is already published."; + if ( + args.effect === "publish" && + args.liveState.published !== "draft" && + args.liveState.published !== "published" + ) { + return "Builder publication state could not be verified; refresh and re-review."; } if (args.effect === "unpublish" && args.liveState.published !== "published") { return "Entry is not currently published."; diff --git a/templates/content/changelog/2026-07-24-builder-can-now-publish-approved-updates-to-entries-that-are.md b/templates/content/changelog/2026-07-24-builder-can-now-publish-approved-updates-to-entries-that-are.md new file mode 100644 index 0000000000..d386d2b459 --- /dev/null +++ b/templates/content/changelog/2026-07-24-builder-can-now-publish-approved-updates-to-entries-that-are.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-24 +--- + +Builder can now publish approved updates to entries that are already live. From 54fb9c890143f39cccce6295b2806c0f3b203a46 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:45:12 -0400 Subject: [PATCH 40/51] fix(content): persist native date selections --- .../DocumentProperties.date.ui.test.tsx | 114 ++++++++++++++++++ .../components/editor/DocumentProperties.tsx | 16 ++- ...ave-reliably-when-selected-with-the-nat.md | 6 + 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 templates/content/app/components/editor/DocumentProperties.date.ui.test.tsx create mode 100644 templates/content/changelog/2026-07-24-date-properties-now-save-reliably-when-selected-with-the-nat.md diff --git a/templates/content/app/components/editor/DocumentProperties.date.ui.test.tsx b/templates/content/app/components/editor/DocumentProperties.date.ui.test.tsx new file mode 100644 index 0000000000..9016a9c690 --- /dev/null +++ b/templates/content/app/components/editor/DocumentProperties.date.ui.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom + +import type { DocumentProperty } from "@shared/api"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const setPropertyMutation = vi.hoisted(() => ({ + mutateAsync: vi.fn(async () => ({})), + isPending: false, +})); + +vi.mock("@agent-native/core/client/i18n", () => ({ + useT: () => (key: string, options?: Record) => { + if (key === "editor.properties.editProperty") { + return `Edit ${String(options?.name)}`; + } + if (key === "editor.properties.editStartDate") { + return `Edit ${String(options?.name)} start date`; + } + return key; + }, +})); + +vi.mock("@/hooks/use-document-properties", async (importOriginal) => ({ + ...(await importOriginal()), + useSetDocumentProperty: () => setPropertyMutation, +})); + +import { PropertyValuePopover } from "./DocumentProperties"; + +const dateProperty: DocumentProperty = { + definition: { + id: "date", + databaseId: "database", + name: "Date", + type: "date", + visibility: "always_show", + options: {}, + position: 0, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + value: null, + editable: true, +}; + +describe("date property editor", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + setPropertyMutation.mutateAsync.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root.render( + + Empty + , + ); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + document.body.replaceChildren(); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = false; + }); + + it("saves the native date control's live value when React state lags", async () => { + const trigger = container.querySelector( + 'button[aria-label="Edit Date"]', + ); + expect(trigger).not.toBeNull(); + + await act(async () => trigger?.click()); + + const input = container.querySelector( + 'input[aria-label="Edit Date start date"]', + ); + expect(input?.type).toBe("date"); + + // Reproduce a native date-picker commit before React's onChange state has + // caught up: the control visibly contains the date, but no event fired. + if (input) input.value = "2026-07-24"; + + const save = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "editor.properties.save", + ); + await act(async () => save?.click()); + + expect(setPropertyMutation.mutateAsync).toHaveBeenCalledTimes(1); + expect(setPropertyMutation.mutateAsync).toHaveBeenCalledWith({ + documentId: "document", + propertyId: "date", + value: { + start: "2026-07-24", + includeTime: false, + }, + }); + }); +}); diff --git a/templates/content/app/components/editor/DocumentProperties.tsx b/templates/content/app/components/editor/DocumentProperties.tsx index 1448b4c5ef..68af097f0b 100644 --- a/templates/content/app/components/editor/DocumentProperties.tsx +++ b/templates/content/app/components/editor/DocumentProperties.tsx @@ -2595,7 +2595,20 @@ function DateValueEditor({ className="grid gap-2" onSubmit={(event) => { event.preventDefault(); - void save(); + const formData = new FormData(event.currentTarget); + const submittedStartValue = formData.get("property-start-value"); + const submittedEndValue = formData.get("property-end-value"); + + // Native date controls can update their displayed DOM value before + // React receives the corresponding change event. Read the submitted + // form so Save never clears a date that is visibly present. + void save( + buildValue( + typeof submittedStartValue === "string" ? submittedStartValue : "", + typeof submittedEndValue === "string" ? submittedEndValue : "", + formData.has("property-include-time"), + ), + ); }} >
@@ -2690,6 +2703,7 @@ function DateValueEditor({