fix: note list shows incomplete relationships when opening from sidebar

Two root causes:
1. noteListHooks used a stale selection.entry to build relationship groups —
   now looks up the fresh entry from the entries array so relationship updates
   propagate immediately.
2. frontmatterToEntryPatch didn't update entry.relationships — added
   RelationshipPatch support so frontmatter changes also update the
   relationships map in state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-20 03:26:52 +01:00
parent 51fc9298aa
commit 586e488072
7 changed files with 342 additions and 20 deletions

View File

@@ -486,6 +486,70 @@ References:
);
}
// --- large relationship array (regression: No Code note with 32 Notes) ---
#[test]
fn test_parse_large_notes_relationship_array() {
let dir = TempDir::new().unwrap();
let content = r#"---
type: Topic
Referred by Data:
- "[[michele-sampieri|Michele Sampieri]]"
- "[[varun-anand|Varun Anand]]"
Belongs to:
- "[[engineering|Engineering]]"
aliases:
- No Code
Notes:
- "[[8020-we-help-companies-move-faster-without-code|8020 | We help companies move faster without code.]]"
- "[[airdev-build-hub|Airdev Build Hub]]"
- "[[airdev-leader-in-bubble-and-no-code-development|AirDev | Leader in Bubble and No-Code Development]]"
- "[[budibase-internal-tools-made-easy|Budibase - Internal tools made easy]]"
- "[[bullet-launch-bubble-boilerplate|Bullet Launch Bubble Boilerplate]]"
- "[[canvas-base-template-for-bubble|Canvas • Base Template for Bubble]]"
- "[[chameleon-microsurveys|Chameleon | Microsurveys]]"
- "[[felt-the-best-way-to-make-maps-on-the-internet|Felt The best way to make maps on the internet]]"
- "[[flutterflow-build-native-apps-visually|FlutterFlow | Build Native Apps Visually]]"
- "[[framer-ai-generate-and-publish-your-site-with-ai-in-seconds|Framer AI — Generate and publish your site with AI in seconds.]]"
- "[[jumpstart-pro-the-best-ruby-on-rails-saas-template|Jumpstart Pro | The best Ruby on Rails SaaS Template]]"
- "[[mailparser-email-parser-software-workflow-automation|MailParser • Email Parser Software & Workflow Automation]]"
- "[[make-work-the-way-you-imagine|Make | Work the way you imagine]]"
- "[[michele-sampieri|Michele Sampieri]]"
- "[[n8nio-a-powerful-workflow-automation-tool|n8n.io - a powerful workflow automation tool]]"
- "[[n8nio-ai-workflow-automation-tool|n8n.io - AI workflow automation tool]]"
- "[[nocodey-find-best-nocoder|Nocodey • Find Best Nocoder]]"
- "[[outseta-software-for-subscription-start-ups|Outseta | Software for subscription start-ups]]"
- "[[payments-tax-subscriptions-for-software-companies-lemon-squeezy|Payments, tax & subscriptions for software companies • Lemon Squeezy]]"
- "[[retool-portals-custom-client-portal-software|Retool Portals • Custom Client Portal Software]]"
- "[[rise-of-the-no-code-economy-report-formstack|Rise of the No-Code Economy Report | Formstack]]"
- "[[scene-the-smart-way-to-build-websites|Scene • The smart way to build websites]]"
- "[[scrapingbee-the-best-web-scraping-api|ScrapingBee • the best web scraping API]]"
- "[[softr-build-a-website-web-app-or-portal-on-airtable-without-code|Softr | Build a website, web app or portal on Airtable without code]]"
- "[[superblocks-build-modern-internal-apps-in-days-not-months|Superblocks • Build modern internal apps in days, not months]]"
- "[[superwall-quickly-deploy-paywalls|Superwall • Quickly deploy paywalls]]"
- "[[tails-tailwind-css-page-creator|Tails | Tailwind CSS Page Creator]]"
- "[[the-open-source-firebase-alternative-supabase|The Open Source Firebase Alternative | Supabase]]"
- "[[varun-anand|Varun Anand]]"
- "[[xano-the-fastest-no-code-backend-development-platform|Xano - The Fastest No Code Backend Development Platform]]"
- "[[directus-open-data-platform-for-headless-content-management|{'Directus': 'Open Data Platform for Headless Content Management'}]]"
- "[[framer-design-beautiful-websites-in-minutes|{'Framer': 'Design beautiful websites in minutes'}]]"
title: No Code
---
# No Code
"#;
create_test_file(dir.path(), "no-code.md", content);
let entry = parse_md_file(&dir.path().join("no-code.md")).unwrap();
let notes = entry.relationships.get("Notes").expect("Notes relationship should exist");
assert_eq!(notes.len(), 32, "All 32 Notes entries should be parsed");
let referred = entry.relationships.get("Referred by Data").expect("Referred by Data should exist");
assert_eq!(referred.len(), 2);
let belongs = entry.relationships.get("Belongs to").expect("Belongs to should exist");
assert_eq!(belongs.len(), 1);
}
// --- type from frontmatter only (no folder inference) ---
#[test]

View File

@@ -56,7 +56,10 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
const groups = buildRelationshipGroups(selection.entry, entries)
// Look up the fresh entry from the entries array to pick up relationship
// updates that happened after the selection was captured.
const freshEntry = entries.find((e) => e.path === selection.entry.path) ?? selection.entry
const groups = buildRelationshipGroups(freshEntry, entries)
return filterGroupsByQuery(groups, query)
}, [isEntityView, selection, entries, query])

View File

@@ -13,12 +13,38 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
template: { template: null }, sort: { sort: null }, visible: { visible: null },
}
/** Check if a string contains a wikilink pattern `[[...]]`. */
function isWikilink(s: string): boolean {
return s.startsWith('[[') && s.includes(']]')
}
/** Extract wikilink strings from a FrontmatterValue. Returns empty array if none. */
function extractWikilinks(value: FrontmatterValue): string[] {
if (typeof value === 'string') return isWikilink(value) ? [value] : []
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string' && isWikilink(v))
return []
}
/**
* Relationship patch: a partial update to merge into `entry.relationships`.
* Keys map to their new ref arrays. A `null` value means "remove this key".
*/
export type RelationshipPatch = Record<string, string[] | null>
export interface EntryPatchResult {
patch: Partial<VaultEntry>
relationshipPatch: RelationshipPatch | null
}
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
export function frontmatterToEntryPatch(
op: 'update' | 'delete', key: string, value?: FrontmatterValue,
): Partial<VaultEntry> {
): EntryPatchResult {
const k = key.toLowerCase().replace(/\s+/g, '_')
if (op === 'delete') return ENTRY_DELETE_MAP[k] ?? {}
if (op === 'delete') {
const relPatch: RelationshipPatch = { [key]: null }
return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch }
}
const str = value != null ? String(value) : null
const arr = Array.isArray(value) ? value.map(String) : []
const updates: Record<string, Partial<VaultEntry>> = {
@@ -32,7 +58,11 @@ export function frontmatterToEntryPatch(
view: { view: str },
visible: { visible: value === false ? false : null },
}
return updates[k] ?? {}
// Also update the relationships map for wikilink-containing values
const wikilinks = value != null ? extractWikilinks(value) : []
const relationshipPatch: RelationshipPatch | null =
wikilinks.length > 0 ? { [key]: wikilinks } : null
return { patch: updates[k] ?? {}, relationshipPatch }
}
async function invokeFrontmatter(command: string, args: Record<string, unknown>): Promise<string> {
@@ -65,18 +95,42 @@ export interface FrontmatterOpOptions {
silent?: boolean
}
/** Apply a relationship patch by merging into the existing relationships map. */
export function applyRelationshipPatch(
existing: Record<string, string[]>, relPatch: RelationshipPatch,
): Record<string, string[]> {
const merged = { ...existing }
for (const [k, v] of Object.entries(relPatch)) {
if (v === null) delete merged[k]
else merged[k] = v
}
return merged
}
/** Run a frontmatter update/delete and apply the result to state.
* Returns the new file content on success, or undefined on failure. */
export async function runFrontmatterAndApply(
op: 'update' | 'delete', path: string, key: string, value: FrontmatterValue | undefined,
callbacks: { updateTab: (p: string, c: string) => void; updateEntry: (p: string, patch: Partial<VaultEntry>) => void; toast: (m: string | null) => void },
callbacks: {
updateTab: (p: string, c: string) => void
updateEntry: (p: string, patch: Partial<VaultEntry>) => void
toast: (m: string | null) => void
getEntry?: (p: string) => VaultEntry | undefined
},
options?: FrontmatterOpOptions,
): Promise<string | undefined> {
try {
const newContent = await executeFrontmatterOp(op, path, key, value)
callbacks.updateTab(path, newContent)
const patch = frontmatterToEntryPatch(op, key, value)
if (Object.keys(patch).length > 0) callbacks.updateEntry(path, patch)
const { patch, relationshipPatch } = frontmatterToEntryPatch(op, key, value)
const fullPatch = { ...patch }
if (relationshipPatch && callbacks.getEntry) {
const current = callbacks.getEntry(path)
if (current) {
fullPatch.relationships = applyRelationshipPatch(current.relationships, relationshipPatch)
}
}
if (Object.keys(fullPatch).length > 0) callbacks.updateEntry(path, fullPatch)
if (!options?.silent) callbacks.toast(op === 'update' ? 'Property updated' : 'Property deleted')
return newContent
} catch (err) {

View File

@@ -19,7 +19,7 @@ import {
resolveTemplate,
} from './useNoteCreation'
import { needsRenameOnSave } from './useNoteRename'
import { frontmatterToEntryPatch } from './frontmatterOps'
import { frontmatterToEntryPatch, applyRelationshipPatch } from './frontmatterOps'
import { useNoteActions } from './useNoteActions'
import type { NoteActionsConfig } from './useNoteActions'
@@ -345,25 +345,45 @@ describe('frontmatterToEntryPatch', () => {
] as [string, unknown, Partial<VaultEntry>][])(
'maps %s update to correct entry field',
(key, value, expected) => {
expect(frontmatterToEntryPatch('update', key, value as never)).toEqual(expected)
expect(frontmatterToEntryPatch('update', key, value as never).patch).toEqual(expected)
},
)
it('maps aliases update with array value', () => {
expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B'])).toEqual({ aliases: ['A', 'B'] })
expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B']).patch).toEqual({ aliases: ['A', 'B'] })
})
it('maps belongs_to update with array value', () => {
expect(frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])).toEqual({ belongsTo: ['[[parent]]'] })
const result = frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])
expect(result.patch).toEqual({ belongsTo: ['[[parent]]'] })
// Also produces a relationship patch for the wikilink
expect(result.relationshipPatch).toEqual({ 'belongs_to': ['[[parent]]'] })
})
it('handles case-insensitive keys with spaces (e.g. "Is A", "Belongs to")', () => {
expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment')).toEqual({ isA: 'Experiment' })
expect(frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]'])).toEqual({ belongsTo: ['[[x]]'] })
expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment').patch).toEqual({ isA: 'Experiment' })
const result = frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]'])
expect(result.patch).toEqual({ belongsTo: ['[[x]]'] })
expect(result.relationshipPatch).toEqual({ 'Belongs to': ['[[x]]'] })
})
it('returns empty object for unknown keys', () => {
expect(frontmatterToEntryPatch('update', 'custom_field', 'value')).toEqual({})
it('returns empty patch for unknown non-wikilink keys', () => {
const result = frontmatterToEntryPatch('update', 'custom_field', 'value')
expect(result.patch).toEqual({})
// Non-wikilink value → no relationship change
expect(result.relationshipPatch).toBeNull()
})
it('produces relationship patch for wikilink values on unknown keys', () => {
const result = frontmatterToEntryPatch('update', 'Notes', ['[[note-a]]', '[[note-b|Note B]]'])
expect(result.patch).toEqual({})
expect(result.relationshipPatch).toEqual({ Notes: ['[[note-a]]', '[[note-b|Note B]]'] })
})
it('produces relationship patch for single wikilink string', () => {
const result = frontmatterToEntryPatch('update', 'Owner', '[[person/alice]]')
expect(result.patch).toEqual({})
expect(result.relationshipPatch).toEqual({ Owner: ['[[person/alice]]'] })
})
it.each([
@@ -377,12 +397,46 @@ describe('frontmatterToEntryPatch', () => {
] as [string, Partial<VaultEntry>][])(
'maps delete of %s to null/default',
(key, expected) => {
expect(frontmatterToEntryPatch('delete', key)).toEqual(expected)
expect(frontmatterToEntryPatch('delete', key).patch).toEqual(expected)
},
)
it('returns empty object for unknown key on delete', () => {
expect(frontmatterToEntryPatch('delete', 'unknown_key')).toEqual({})
it('returns empty patch for unknown key on delete, with relationship removal', () => {
const result = frontmatterToEntryPatch('delete', 'unknown_key')
expect(result.patch).toEqual({})
expect(result.relationshipPatch).toEqual({ unknown_key: null })
})
it('delete of known key also produces relationship removal', () => {
const result = frontmatterToEntryPatch('delete', 'status')
expect(result.patch).toEqual({ status: null })
expect(result.relationshipPatch).toEqual({ status: null })
})
})
describe('applyRelationshipPatch', () => {
it('adds new relationship key', () => {
const existing = { 'Belongs to': ['[[eng]]'] }
const result = applyRelationshipPatch(existing, { Notes: ['[[note-a]]', '[[note-b]]'] })
expect(result).toEqual({ 'Belongs to': ['[[eng]]'], Notes: ['[[note-a]]', '[[note-b]]'] })
})
it('overwrites existing relationship key', () => {
const existing = { Notes: ['[[old]]'] }
const result = applyRelationshipPatch(existing, { Notes: ['[[new-a]]', '[[new-b]]'] })
expect(result).toEqual({ Notes: ['[[new-a]]', '[[new-b]]'] })
})
it('removes relationship key when value is null', () => {
const existing = { Notes: ['[[a]]'], Owner: ['[[alice]]'] }
const result = applyRelationshipPatch(existing, { Notes: null })
expect(result).toEqual({ Owner: ['[[alice]]'] })
})
it('does not mutate the original map', () => {
const existing = { Notes: ['[[a]]'] }
applyRelationshipPatch(existing, { Notes: ['[[b]]'] })
expect(existing).toEqual({ Notes: ['[[a]]'] })
})
})

View File

@@ -117,8 +117,8 @@ export function useNoteActions(config: NoteActionsConfig) {
const runFrontmatterOp = useCallback(
(op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue, options?: FrontmatterOpOptions) =>
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage }, options),
[updateTabContent, updateEntry, setToastMessage],
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options),
[updateTabContent, updateEntry, setToastMessage, entries],
)
return {

View File

@@ -324,6 +324,50 @@ describe('buildRelationshipGroups', () => {
const groups = buildRelationshipGroups(entity, [entity, linker])
expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker')
})
it('resolves all entries in a large Notes relationship (regression: No Code)', () => {
// Simulates the No Code topic note with 32 Notes, 2 Referred by Data, 1 Belongs to
const noteRefs = [
'8020', 'airdev-build-hub', 'airdev-leader', 'budibase', 'bullet-launch',
'canvas', 'chameleon', 'felt', 'flutterflow', 'framer-ai',
'jumpstart', 'mailparser', 'make', 'michele-sampieri', 'n8n-a',
'n8n-ai', 'nocodey', 'outseta', 'lemon-squeezy', 'retool',
'rise-no-code', 'scene', 'scrapingbee', 'softr', 'superblocks',
'superwall', 'tails', 'supabase', 'varun-anand', 'xano',
'directus', 'framer-design',
]
const noteEntries = noteRefs.map((slug, i) => makeEntry({
path: `/Laputa/${slug}.md`, filename: `${slug}.md`, title: `Title ${slug}`,
modifiedAt: 1700000000 - i * 100,
}))
const engineering = makeEntry({
path: '/Laputa/engineering.md', filename: 'engineering.md', title: 'Engineering',
modifiedAt: 1700000000,
})
const entity = makeEntry({
path: '/Laputa/no-code.md', filename: 'no-code.md', title: 'No Code',
isA: 'Topic',
relationships: {
'Belongs to': ['[[engineering|Engineering]]'],
Notes: noteRefs.map((slug) => `[[${slug}|Title ${slug}]]`),
'Referred by Data': ['[[michele-sampieri|Michele Sampieri]]', '[[varun-anand|Varun Anand]]'],
},
})
const allEntries = [entity, engineering, ...noteEntries]
const groups = buildRelationshipGroups(entity, allEntries)
const belongsGroup = groups.find((g) => g.label === 'Belongs to')
expect(belongsGroup).toBeDefined()
expect(belongsGroup!.entries).toHaveLength(1)
const notesGroup = groups.find((g) => g.label === 'Notes')
expect(notesGroup).toBeDefined()
expect(notesGroup!.entries).toHaveLength(32)
// michele-sampieri and varun-anand already consumed by Notes → Referred by Data has 0 new
const referredGroup = groups.find((g) => g.label === 'Referred by Data')
expect(referredGroup).toBeUndefined()
})
})
describe('getSortComparator — custom properties', () => {

View File

@@ -0,0 +1,103 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
test.describe('Note list shows complete relationships when opening from sidebar', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 })
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('entity view shows relationship groups with correct counts after sidebar click', async ({ page }) => {
// Navigate to Responsibilities type to find entity notes in the sidebar
// First, click on a sidebar type that contains entity notes
// We'll use the sidebar search to filter to "Sponsorships"
const searchToggle = page.locator('[data-testid="search-toggle"]')
if (await searchToggle.isVisible()) {
await searchToggle.click()
}
// Type in sidebar search to find Sponsorships
const sidebarSearch = page.locator('[data-testid="note-list-search"]')
if (await sidebarSearch.isVisible()) {
await sidebarSearch.fill('Sponsorships')
await page.waitForTimeout(500)
}
// Click the Sponsorships note in the note list to trigger entity view
const sponsorshipsItem = page.locator('[data-entry-path*="sponsorships"]').first()
if (await sponsorshipsItem.isVisible({ timeout: 3000 }).catch(() => false)) {
await sponsorshipsItem.click()
await page.waitForTimeout(1000)
} else {
// Fallback: open via quick open — then click in sidebar
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
const searchInput = page.locator('input[placeholder="Search notes..."]')
await expect(searchInput).toBeVisible()
await searchInput.fill('Sponsorships')
await page.waitForTimeout(500)
await page.keyboard.press('Enter')
await page.waitForTimeout(1000)
}
// The inspector panel (right side) should show relationship labels with font-mono-overline
// This verifies the note loaded and relationships were parsed correctly
const measuresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' })
await expect(measuresLabel).toBeVisible({ timeout: 5000 })
const proceduresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Procedures' })
await expect(proceduresLabel).toBeVisible({ timeout: 5000 })
// Verify the relationships panel shows the correct number of items
// Has Measures should have 2 entries (measure-sponsorship-mrr, measure-close-rate)
const measuresSection = measuresLabel.locator('xpath=ancestor::div[1]')
const measureItems = measuresSection.locator('a, button').filter({ hasText: /measure|Measure/ })
// At least 1 resolved relationship entry
const hasItems = await measureItems.count()
expect(hasItems).toBeGreaterThanOrEqual(1)
})
test('relationship labels persist after navigating away and back', async ({ page }) => {
// Open Sponsorships via quick open
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
const searchInput = page.locator('input[placeholder="Search notes..."]')
await expect(searchInput).toBeVisible()
await searchInput.fill('Sponsorships')
await page.waitForTimeout(500)
await page.keyboard.press('Enter')
await page.waitForTimeout(1000)
// Verify Has Measures relationship appears in inspector
const measuresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' })
await expect(measuresLabel).toBeVisible({ timeout: 5000 })
// Navigate to different note
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
const searchInput2 = page.locator('input[placeholder="Search notes..."]')
await expect(searchInput2).toBeVisible()
await searchInput2.fill('Start Laputa App')
await page.waitForTimeout(500)
await page.keyboard.press('Enter')
await page.waitForTimeout(1000)
// Navigate back to Sponsorships
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
const searchInput3 = page.locator('input[placeholder="Search notes..."]')
await expect(searchInput3).toBeVisible()
await searchInput3.fill('Sponsorships')
await page.waitForTimeout(500)
await page.keyboard.press('Enter')
await page.waitForTimeout(1000)
// Relationships should still be visible — not stale or incomplete
const measuresLabelAgain = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' })
await expect(measuresLabelAgain).toBeVisible({ timeout: 5000 })
const proceduresLabelAgain = page.locator('span.font-mono-overline').filter({ hasText: 'Has Procedures' })
await expect(proceduresLabelAgain).toBeVisible({ timeout: 5000 })
})
})