Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/preview-scoped-updates.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 14 additions & 2 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions packages/client/src/content.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>

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()
})
})
37 changes: 36 additions & 1 deletion packages/client/src/content.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,39 @@
import type { B10cksLink } from './types'
import type { B10cksLink, IBContent } from './types'

export type RootBlock<T> = 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<T extends Record<string, unknown>>(entry: IBContent<T>): RootBlock<T>
export function toRootBlock<T extends Record<string, unknown>>(
entry: IBContent<T> | null | undefined
): RootBlock<T> | null
export function toRootBlock<T extends Record<string, unknown>>(
entry: IBContent<T> | null | undefined
): RootBlock<T> | 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<T>
}

export interface B10cksLinkResolved {
href: string
Expand Down
19 changes: 17 additions & 2 deletions packages/client/src/data-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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')
})
})
12 changes: 10 additions & 2 deletions packages/client/src/data-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>),
id: configEntry.id,
} as T
if (!bypassCache) {
this.setConfigCache(cacheKey, value)
}
Expand Down
102 changes: 101 additions & 1 deletion packages/client/src/preview-store.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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' })
Expand All @@ -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<typeof initial>(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<Record<string, unknown>>({
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)
Expand Down
Loading