From 161b1031f11a4329b2a903ec3ee9d3c1f0c78efb Mon Sep 17 00:00:00 2001 From: Michael Wallner Date: Tue, 11 Aug 2026 23:30:11 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Merge=20scoped=20preview=20updat?= =?UTF-8?q?es=20and=20fix=20duplicate=20Vue=20instances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor sends CONTENT_UPDATE scoped to the edited block, which the preview store applied as the new root and collapsed the page to that block. Merge by block id instead, and make the root block addressable. - PreviewStore.applyContentUpdate / mergeContentUpdate merge by id - toRootBlock(entry) carries the entry id into the root block - getConfig() returns the config entry id so v-editable="config" works - Vue injection keys use Symbol.for so a duplicated @b10cks/vue copy cannot break useB10cksApi() during hydration - Nuxt dedupes/excludes the b10cks packages in Vite and transpiles them by bare specifier Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E6isfVxvUpARWAFeWhxUko --- .changeset/preview-scoped-updates.md | 13 +++ packages/client/README.md | 16 +++- packages/client/src/content.test.ts | 43 +++++++++ packages/client/src/content.ts | 37 +++++++- packages/client/src/data-api.test.ts | 19 +++- packages/client/src/data-api.ts | 12 ++- packages/client/src/preview-store.test.ts | 102 +++++++++++++++++++- packages/client/src/preview-store.ts | 109 +++++++++++++++++++++- packages/nuxt/README.md | 22 +++-- packages/nuxt/src/module.ts | 32 ++++++- packages/vue/README.md | 9 +- packages/vue/src/index.ts | 2 +- packages/vue/src/types.ts | 13 ++- 13 files changed, 404 insertions(+), 25 deletions(-) create mode 100644 .changeset/preview-scoped-updates.md create mode 100644 packages/client/src/content.test.ts diff --git a/.changeset/preview-scoped-updates.md b/.changeset/preview-scoped-updates.md new file mode 100644 index 0000000..e250186 --- /dev/null +++ b/.changeset/preview-scoped-updates.md @@ -0,0 +1,13 @@ +--- +'@b10cks/client': minor +'@b10cks/vue': minor +'@b10cks/nuxt': minor +--- + +Fix live preview losing the page on a scoped edit, and duplicate-instance injection failures + +- `PreviewStore` now merges `CONTENT_UPDATE` by block `id` (`applyContentUpdate` / `mergeContentUpdate`) instead of replacing the root. The editor sends updates scoped to the edited block, which previously collapsed the whole preview to that block. Unknown ids are ignored. +- Vue injection keys use `Symbol.for(...)`, so a duplicated `@b10cks/vue` copy (Vite dep pre-bundling) can no longer break `useB10cksApi()` during hydration. +- `@b10cks/nuxt` registers `@b10cks/vue`, `@b10cks/client` and `@b10cks/richtext` in `vite.resolve.dedupe` and `optimizeDeps.exclude`, and transpiles them by bare specifier (the previous `resolver.resolve('@b10cks/vue')` produced a nonexistent path). +- New `toRootBlock(entry)` helper (`@b10cks/client`, re-exported from `@b10cks/vue`, auto-imported in Nuxt) returns `{ ...entry.content, id, block }` so the root block is selectable with `v-editable` and root-level editor updates match it. READMEs updated. +- `getConfig()` now includes the config entry's `id` in its result, so `v-editable="config"` works for config-driven regions. Potentially breaking only for a config schema with its own `id` field, which the entry id now shadows. diff --git a/packages/client/README.md b/packages/client/README.md index bcdd34b..d4fd50f 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -144,7 +144,7 @@ const params = serializeFilter({ | `getSitemap(params, options)` | Sitemap entries (default sitemap) | | `getNamedSitemap(name, params, options)` | Entries of a named sitemap from `settings.sitemaps` | | `getSpace(params)` | Current space info | -| `getConfig(options)` | Config content entry (cached) | +| `getConfig(options)` | Config entry content plus its `id` (cached) | | `syncRevision(fallbackRv)` | Sync local RV from the space | | `clearCache()` | Clear redirect and config caches | @@ -386,8 +386,20 @@ setPreviewScrollOffset(80) // number → px, or pass a string like '5rem' `PreviewStore` is a framework-agnostic, subscribable holder for the content tree. `bindPreviewStore` feeds `CONTENT_UPDATE`/`CONTENT_PATCH` events into it, so any complex field — including rich text — re-renders from the new snapshot. The framework packages expose this as `usePreviewContent` / `createPreviewContent`. +The editor sends `CONTENT_UPDATE` scoped to the block that changed, carrying that block's `id`; only an edit of the root block pushes the whole tree (as `{ id: entryId, …entryContent }`). The store therefore merges an update by `id`: + +- payload without an `id` → treated as the whole tree +- id equal to the root's id → replaces the root +- id found in the tree → replaces that node in place, immutably +- id found nowhere → ignored, so a scoped update can never collapse the page + +Give the root block the entry's id with `toRootBlock` so root-level edits match it; when the root has no id, an update whose `block` type equals the root's is taken as the root. + ```typescript -import { PreviewStore, bindPreviewStore, setAtPath, getAtPath } from '@b10cks/client' +import { PreviewStore, bindPreviewStore, setAtPath, getAtPath, toRootBlock } from '@b10cks/client' + +// `entry.content` has no id of its own — the id lives on the entry. +const initialContent = toRootBlock(entry) // { ...entry.content, id, block } const store = new PreviewStore(initialContent) const offBridge = bindPreviewStore(store) diff --git a/packages/client/src/content.test.ts b/packages/client/src/content.test.ts new file mode 100644 index 0000000..3d1fff7 --- /dev/null +++ b/packages/client/src/content.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { toRootBlock } from './content' +import type { IBContent } from './types' + +const entry = { + id: 'entry-1', + name: 'Home', + slug: 'home', + block: 'page', + parent_id: null, + full_slug: 'home', + content: { block: 'page', body: [{ id: 'hero', block: 'hero' }] }, + language_iso: 'en', + translations: [], + published_at: null, + first_published_at: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +} as unknown as IBContent> + +describe('toRootBlock', () => { + it('carries the entry id into the root block', () => { + const block = toRootBlock(entry) + + expect(block).toEqual({ + id: 'entry-1', + block: 'page', + body: [{ id: 'hero', block: 'hero' }], + }) + }) + + it('falls back to the entry block when the content object has none', () => { + const block = toRootBlock({ ...entry, content: { headline: 'hi' } }) + + expect(block).toEqual({ id: 'entry-1', block: 'page', headline: 'hi' }) + }) + + it('returns null for a missing entry', () => { + expect(toRootBlock(null)).toBeNull() + expect(toRootBlock(undefined)).toBeNull() + }) +}) diff --git a/packages/client/src/content.ts b/packages/client/src/content.ts index d128ec1..8cb7d6f 100644 --- a/packages/client/src/content.ts +++ b/packages/client/src/content.ts @@ -1,4 +1,39 @@ -import type { B10cksLink } from './types' +import type { B10cksLink, IBContent } from './types' + +export type RootBlock = T & { id: string; block: string } + +/** + * Flatten a content entry into a renderable root block. + * + * An entry's `content` object carries `block` but not `id` — the id lives on the + * entry. Rendering `entry.content` directly therefore yields a root block the + * visual editor cannot address: `v-editable` no-ops on it, and the editor's + * root-level `CONTENT_UPDATE` (sent as `{ id: entryId, … }`) matches nothing. + * Use this helper to carry the entry id into the tree: + * + * ```ts + * const block = toRootBlock(entry) // { ...entry.content, id, block } + * ``` + */ +export function toRootBlock>(entry: IBContent): RootBlock +export function toRootBlock>( + entry: IBContent | null | undefined +): RootBlock | null +export function toRootBlock>( + entry: IBContent | null | undefined +): RootBlock | null { + if (!entry) { + return null + } + + const content = (entry.content ?? {}) as T & { block?: string } + + return { + ...content, + id: entry.id, + block: content.block ?? entry.block, + } as RootBlock +} export interface B10cksLinkResolved { href: string diff --git a/packages/client/src/data-api.test.ts b/packages/client/src/data-api.test.ts index 5b3da8f..31d1148 100644 --- a/packages/client/src/data-api.test.ts +++ b/packages/client/src/data-api.test.ts @@ -283,8 +283,8 @@ describe('B10cksDataApi', () => { const draftConfig = await dataApi.getConfig<{ theme: string }>(draftOptions) const publishedConfig = await dataApi.getConfig<{ theme: string }>(publishedOptions) - expect(draftConfig).toEqual({ theme: 'draft' }) - expect(publishedConfig).toEqual({ theme: 'published' }) + expect(draftConfig).toEqual({ theme: 'draft', id: 'config-1' }) + expect(publishedConfig).toEqual({ theme: 'published', id: 'config-2' }) expect(client.get).toHaveBeenNthCalledWith(1, 'contents/_config', { vid: 'draft', language_iso: undefined, @@ -294,4 +294,19 @@ describe('B10cksDataApi', () => { language_iso: undefined, }) }) + + it('exposes the config entry id so the config is editable in the visual editor', async () => { + const client: DataApiClient = { + get: vi.fn().mockResolvedValue({ data: buildContent('config-entry', 'dark') }), + getAll: vi.fn(), + setRv: vi.fn(), + } + + const dataApi = new B10cksDataApi(client) + + const config = await dataApi.getConfig<{ theme: string; id: string }>() + + expect(config.id).toBe('config-entry') + expect(config.theme).toBe('dark') + }) }) diff --git a/packages/client/src/data-api.ts b/packages/client/src/data-api.ts index 519dc1f..0730a00 100644 --- a/packages/client/src/data-api.ts +++ b/packages/client/src/data-api.ts @@ -369,8 +369,16 @@ export class B10cksDataApi { ...params, language_iso: normalizedLanguage, }) - .then((configContent) => { - const value = (configContent.content ?? {}) as T + .then((configEntry) => { + // Carry the entry id into the returned object so the config is + // addressable in the visual editor (`v-editable="config"`); without it + // config-driven regions like a header/footer are not selectable. The + // entry id wins over a same-named content field, which would not be a + // block id. + const value = { + ...((configEntry.content ?? {}) as Record), + id: configEntry.id, + } as T if (!bypassCache) { this.setConfigCache(cacheKey, value) } diff --git a/packages/client/src/preview-store.test.ts b/packages/client/src/preview-store.test.ts index 8614a1d..4a0d577 100644 --- a/packages/client/src/preview-store.test.ts +++ b/packages/client/src/preview-store.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import { getAtPath, PreviewStore, setAtPath } from './preview-store' +import { + findPathById, + getAtPath, + mergeContentUpdate, + PreviewStore, + setAtPath, +} from './preview-store' describe('getAtPath', () => { it('reads nested object and array values', () => { @@ -49,6 +55,52 @@ describe('setAtPath', () => { }) }) +describe('findPathById', () => { + it('finds nested nodes in objects and arrays', () => { + const tree = { + id: 'root', + body: [{ id: 'a', columns: [{ id: 'b' }] }], + hero: { id: 'c' }, + } + + expect(findPathById(tree, 'a')).toEqual(['body', 0]) + expect(findPathById(tree, 'b')).toEqual(['body', 0, 'columns', 0]) + expect(findPathById(tree, 'c')).toEqual(['hero']) + }) + + it('never matches the root itself and returns null for unknown ids', () => { + expect(findPathById({ id: 'root' }, 'root')).toBeNull() + expect(findPathById({ id: 'root' }, 'nope')).toBeNull() + }) +}) + +describe('mergeContentUpdate', () => { + it('treats a payload without an id as the whole tree', () => { + const update = { block: 'page', body: [] } + expect(mergeContentUpdate({ id: 'root', block: 'page' }, update)).toBe(update) + }) + + it('treats a matching block type as the root when the root carries no id', () => { + // The docs shape `usePreviewContent(() => data.value.content)` has no id on + // the root, while the editor pushes `{ id: entryId, ...content }`. + const update = { id: 'entry-1', block: 'page', body: [{ id: 'hero', block: 'hero' }] } + expect(mergeContentUpdate({ block: 'page', body: [] }, update)).toBe(update) + }) + + it('still merges nested updates when the root carries no id', () => { + const root = { block: 'page', body: [{ id: 'hero', block: 'hero', headline: 'old' }] } + const merged = mergeContentUpdate(root, { id: 'hero', block: 'hero', headline: 'new' }) + + expect(merged.block).toBe('page') + expect(merged.body[0]?.headline).toBe('new') + }) + + it('returns the original tree for an unknown id of a different block type', () => { + const root = { block: 'page', body: [] } + expect(mergeContentUpdate(root, { id: 'x', block: 'hero' })).toBe(root) + }) +}) + describe('PreviewStore', () => { it('notifies subscribers on setContent and exposes the snapshot', () => { const store = new PreviewStore<{ title: string }>({ title: 'old' }) @@ -65,6 +117,54 @@ describe('PreviewStore', () => { expect(listener).toHaveBeenCalledTimes(1) // no longer notified }) + it('merges a scoped content update into the tree instead of replacing the root', () => { + const initial = { + id: 'root', + block: 'page', + body: [ + { id: 'hero', block: 'hero', headline: 'old' }, + { id: 'teaser', block: 'teaser' }, + ], + } + const store = new PreviewStore(initial) + const listener = vi.fn() + store.subscribe(listener) + + store.applyContentUpdate({ id: 'hero', block: 'hero', headline: 'new' }) + + const snapshot = store.getSnapshot() + expect(snapshot.block).toBe('page') + expect(snapshot.body).toHaveLength(2) + expect(snapshot.body[0]).toEqual({ id: 'hero', block: 'hero', headline: 'new' }) + expect(snapshot.body[1]).toBe(initial.body[1]) + expect(initial.body[0]?.headline).toBe('old') + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('replaces the whole tree when the update targets the root', () => { + const store = new PreviewStore>({ + id: 'root', + block: 'page', + body: [{ id: 'hero', block: 'hero' }], + }) + + store.applyContentUpdate({ id: 'root', block: 'page', body: [] }) + + expect(store.getSnapshot()).toEqual({ id: 'root', block: 'page', body: [] }) + }) + + it('ignores an update for a block that is not in the tree', () => { + const initial = { id: 'root', block: 'page', body: [{ id: 'hero', block: 'hero' }] } + const store = new PreviewStore(initial) + const listener = vi.fn() + store.subscribe(listener) + + store.applyContentUpdate({ id: 'elsewhere', block: 'hero' }) + + expect(store.getSnapshot()).toBe(initial) + expect(listener).not.toHaveBeenCalled() + }) + it('applies granular patches immutably and notifies', () => { const initial = { body: [{ headline: 'a' }] } const store = new PreviewStore(initial) diff --git a/packages/client/src/preview-store.ts b/packages/client/src/preview-store.ts index 2a50606..a3fd966 100644 --- a/packages/client/src/preview-store.ts +++ b/packages/client/src/preview-store.ts @@ -38,11 +38,102 @@ export function setAtPath(target: T, path: FieldPath, value: unknown): T { return next as T } +/** + * Depth-first search for the path to the node carrying `id`. The root node + * itself is not considered a match — callers handle the root explicitly. + */ +export function findPathById(root: unknown, id: string): FieldPath | null { + const search = (node: unknown, path: FieldPath): FieldPath | null => { + if (Array.isArray(node)) { + for (let index = 0; index < node.length; index++) { + const found = search(node[index], [...path, index]) + if (found) { + return found + } + } + return null + } + + if (!node || typeof node !== 'object') { + return null + } + + const record = node as Record + if (path.length > 0 && record.id === id) { + return path + } + + for (const key of Object.keys(record)) { + const value = record[key] + if (value && typeof value === 'object') { + const found = search(value, [...path, key]) + if (found) { + return found + } + } + } + + return null + } + + return search(root, []) +} + +/** + * Merge a `CONTENT_UPDATE` payload into an existing content tree. + * + * The editor sends updates scoped to the edited item — the payload is the block + * that changed, carrying its own `id` — and only sends the whole tree when the + * root block itself is edited (as `{ id: entryId, ...entryContent }`). Blindly + * assigning the payload as the new root therefore collapses the page to the + * edited block, so the payload is instead matched by `id`: + * + * - payload without an `id` → treated as the whole tree (legacy/whole-tree push) + * - payload id equal to the root's id → replaces the root + * - payload id found in the tree → replaces that node in place, immutably + * - payload id found nowhere → ignored (an update for a block not rendered here) + * + * When the root is rendered without its entry id (`entry.content` alone, so the + * root has no `id` to match) an update that matches nothing nested but has the + * same `block` type as the root is taken as the root. + */ +export function mergeContentUpdate(root: T, update: Record): T { + if (!update || typeof update !== 'object') { + return root + } + + const updateId = typeof update.id === 'string' ? update.id : undefined + if (!updateId) { + return update as unknown as T + } + + if (!root || typeof root !== 'object') { + return update as unknown as T + } + + const rootRecord = root as unknown as Record + const rootId = typeof rootRecord.id === 'string' ? rootRecord.id : undefined + if (rootId === updateId) { + return update as unknown as T + } + + const path = findPathById(root, updateId) + if (path) { + return setAtPath(root, path, update) + } + + if (!rootId && rootRecord.block !== undefined && rootRecord.block === update.block) { + return update as unknown as T + } + + return root +} + type Listener = () => void /** * A framework-agnostic, reactive holder for the content tree shown in the - * preview. The editor pushes whole-tree (`CONTENT_UPDATE`) or granular + * preview. The editor pushes block-scoped (`CONTENT_UPDATE`) or granular * (`CONTENT_PATCH`) changes; subscribers re-render from the new snapshot. */ export class PreviewStore> { @@ -67,6 +158,20 @@ export class PreviewStore> { this.emit() } + /** + * Apply an editor `CONTENT_UPDATE` payload. See {@link mergeContentUpdate}: + * scoped payloads are merged by `id` instead of replacing the whole tree. + * Unknown ids are ignored and do not notify subscribers. + */ + applyContentUpdate(update: Record) { + const next = mergeContentUpdate(this.content, update) + if (next === this.content) { + return + } + this.content = next + this.emit() + } + patch(path: FieldPath, value: unknown) { this.content = setAtPath(this.content, path, value) this.emit() @@ -85,7 +190,7 @@ export class PreviewStore> { */ export function bindPreviewStore(store: PreviewStore): () => void { const offUpdate = previewBridge.on('CONTENT_UPDATE', ({ content }) => { - store.setContent(content as T) + store.applyContentUpdate(content) }) const offPatch = previewBridge.on('CONTENT_PATCH', ({ path, value }) => { store.patch(path, value) diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index f6ac509..9a1aa86 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -104,12 +104,21 @@ The helpers use Nuxt's `useAsyncData()` under the hood, so requests participate `B10cksComponent`, `v-editable`, and `v-editable-field` are available globally after registering the module. `componentsDir` in the config tells the module where your block components live; it auto-registers them by block name. ```vue + + ``` @@ -135,8 +144,9 @@ For whole-tree reactive updates while editing — including nested and rich text const { useContent } = useB10cksApi() const { data } = await useContent('home') // Pass a getter (or ref) so the preview resets when content is refetched -// on a route/locale change instead of keeping the first tree. -const content = usePreviewContent(() => data.value.content) +// on a route/locale change instead of keeping the first tree. `toRootBlock` +// carries the entry id into the root block so root-level edits are matched. +const content = usePreviewContent(() => toRootBlock(data.value))