fix: prevent infinite render loop when creating notes
updateEntry's .map() always returned a new array even when no entry matched, causing unnecessary state changes. During note creation, addEntry uses startTransition (deferred) while markContentPending calls updateEntry synchronously — the entry doesn't exist yet, so the no-op .map() produced a new reference that cascaded into "Maximum update depth exceeded" (which surfaced as React error #185 in the production WKWebView build). The fix makes updateEntry bail out (return prev) when no entry was changed, preventing the spurious state update. Also removes the defensive try-catch from the previous fix attempt and cleans up an unnecessary setToastMessage dependency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0032"
|
||||
title: "Git actions (Changes, Pulse, Commit) in status bar, not sidebar"
|
||||
title: 0032 Status Bar For Git Actions
|
||||
status: active
|
||||
date: 2026-03-31
|
||||
---
|
||||
[[Subfolder scanning and folder tree navigation]]
|
||||
|
||||
## Context
|
||||
|
||||
@@ -16,14 +17,14 @@ The Laputa sidebar originally surfaced git-related affordances — a "Changes" n
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Keep git items in sidebar** (status quo): familiar placement, visible at all times. Rejected — mixes navigation and action concerns; sidebar becomes harder to scan.
|
||||
- **Status bar** (chosen): consistent with app conventions (build number, sync status, vault switcher already live there); persistent but unobtrusive; follows macOS app patterns where status/action items live at window bottom.
|
||||
- **Toolbar / breadcrumb bar**: would require a new chrome layer or polluting the per-note breadcrumb with global git state. Rejected.
|
||||
* **Keep git items in sidebar** (status quo): familiar placement, visible at all times. Rejected — mixes navigation and action concerns; sidebar becomes harder to scan.
|
||||
* **Status bar** (chosen): consistent with app conventions (build number, sync status, vault switcher already live there); persistent but unobtrusive; follows macOS app patterns where status/action items live at window bottom.
|
||||
* **Toolbar / breadcrumb bar**: would require a new chrome layer or polluting the per-note breadcrumb with global git state. Rejected.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Sidebar props `modifiedCount`, `onCommitPush`, `isGitVault` removed; sidebar renders navigation-only
|
||||
- `StatusBar` gains `onClickPending`, `onClickPulse`, `onCommitPush`, `isGitVault` props
|
||||
- Sidebar tests for Changes/Pulse/Commit button removed; StatusBar tests extended
|
||||
- Users find Commit & Push in the status bar (same location as sync indicators) rather than bottom of sidebar — small discoverability change, offset by status bar being always visible regardless of sidebar collapsed state
|
||||
- Triggers re-evaluation if: user research shows git actions are hard to discover in the status bar
|
||||
* Sidebar props `modifiedCount`, `onCommitPush`, `isGitVault` removed; sidebar renders navigation-only
|
||||
* `StatusBar` gains `onClickPending`, `onClickPulse`, `onCommitPush`, `isGitVault` props
|
||||
* Sidebar tests for Changes/Pulse/Commit button removed; StatusBar tests extended
|
||||
* Users find Commit & Push in the status bar (same location as sync indicators) rather than bottom of sidebar — small discoverability change, offset by status bar being always visible regardless of sidebar collapsed state
|
||||
* Triggers re-evaluation if: user research shows git actions are hard to discover in the status bar
|
||||
|
||||
@@ -5,29 +5,30 @@ title: "Subfolder scanning and folder tree navigation"
|
||||
status: active
|
||||
date: 2026-03-31
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
[[0032 Status Bar For Git Actions]]
|
||||
|
||||
Supersedes the scanning constraint in [ADR-0006](0006-flat-vault-structure.md) which limited vault indexing to root-level `.md` files plus protected folders (`attachments/`, `assets/`).
|
||||
|
||||
Users with folder-based workflows (PARA, Zettelkasten with folders, project directories) could not see or filter notes by directory. The vault scanner silently ignored all subdirectory `.md` files, making Laputa unsuitable for vaults with any folder structure.
|
||||
|
||||
## Decision
|
||||
|
||||
**Extend the Rust vault scanner to index `.md` files in all visible subdirectories, and expose the vault's folder tree via a new `list_vault_folders` Tauri command so the sidebar can render a collapsible FOLDERS section.**
|
||||
**Extend the Rust vault scanner to index **`.md`** files in all visible subdirectories, and expose the vault's folder tree via a new **`list_vault_folders`** Tauri command so the sidebar can render a collapsible FOLDERS section.**
|
||||
|
||||
Hidden directories (names starting with `.`, plus `.git` and `.laputa`) are excluded from both scanning and the folder tree.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Scan all subdirectories with `walkdir`, expose separate `list_vault_folders` command — simple, no schema changes to VaultEntry, folder tree is lightweight and independent of the entry cache.
|
||||
- **Option B**: Add a `folder` field to VaultEntry and derive the tree on the frontend — couples folder metadata to the entry cache, complicates cache invalidation when folders are created/deleted without file changes.
|
||||
- **Option C**: Keep flat scanning, add a "virtual folders" feature that groups by path prefix from frontmatter — doesn't solve the core problem of missing notes in subdirectories.
|
||||
* **Option A** (chosen): Scan all subdirectories with `walkdir`, expose separate `list_vault_folders` command — simple, no schema changes to VaultEntry, folder tree is lightweight and independent of the entry cache.
|
||||
* **Option B**: Add a `folder` field to VaultEntry and derive the tree on the frontend — couples folder metadata to the entry cache, complicates cache invalidation when folders are created/deleted without file changes.
|
||||
* **Option C**: Keep flat scanning, add a "virtual folders" feature that groups by path prefix from frontmatter — doesn't solve the core problem of missing notes in subdirectories.
|
||||
|
||||
## Consequences
|
||||
|
||||
- All `.md` files in the vault are now indexed regardless of depth — vaults with many non-note `.md` files (e.g. node_modules) will see spurious entries. Mitigation: hidden directories are already excluded; users can add a `.laputaignore` in the future if needed.
|
||||
- The git-based cache in `cache.rs` already uses `walkdir` for change detection, so this change aligns scanning with caching.
|
||||
- `SidebarSelection` gains a new `{ kind: 'folder'; path: string }` variant — all exhaustive switches on selection kind must handle it.
|
||||
- ADR-0006's "flat vault" principle is relaxed: notes can now live in subdirectories. Type definitions still live in `type/` at the root.
|
||||
- Re-evaluate if users request recursive folder filtering (currently only direct children are shown when a folder is selected).
|
||||
* All `.md` files in the vault are now indexed regardless of depth — vaults with many non-note `.md` files (e.g. node_modules) will see spurious entries. Mitigation: hidden directories are already excluded; users can add a `.laputaignore` in the future if needed.
|
||||
* The git-based cache in `cache.rs` already uses `walkdir` for change detection, so this change aligns scanning with caching.
|
||||
* `SidebarSelection` gains a new `{ kind: 'folder'; path: string }` variant — all exhaustive switches on selection kind must handle it.
|
||||
* ADR-0006's "flat vault" principle is relaxed: notes can now live in subdirectories. Type definitions still live in `type/` at the root.
|
||||
* Re-evaluate if users request recursive folder filtering (currently only direct children are shown when a folder is selected).
|
||||
|
||||
29
docs/adr/0034-git-repo-required-for-vault.md
Normal file
29
docs/adr/0034-git-repo-required-for-vault.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0034"
|
||||
title: "Git repo required — blocking modal enforces vault prerequisite"
|
||||
status: active
|
||||
date: 2026-04-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0014 (git-based vault cache) and ADR-0021 (push-to-main workflow) both assume the vault is a git repository, but neither codified it as a hard enforcement. In practice, opening a non-git folder silently degraded: the cache couldn't compute a commit hash, Pulse/Changes were empty, and commit/push commands failed. The failure mode was invisible to users.
|
||||
|
||||
## Decision
|
||||
|
||||
**When the app opens a vault that has no `.git` directory, a blocking modal prevents all app use until the user either initialises a git repository (git init + initial commit, offered as a one-click action) or selects a different vault. The check is performed by a new `is_git_repo` Tauri command. In browser/dev mode, the check fails open (modal is skipped).**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Hard block via modal on vault open — unambiguous, prevents silent failures, surfaces the fix immediately. Downside: breaks existing workflows for users with non-git vaults; requires a clear escape hatch (choose different vault).
|
||||
- **Option B**: Soft warning banner, allow using the app without git — avoids blocking users, but silent failures persist for Pulse/Changes/commit features.
|
||||
- **Option C**: Auto-init git on vault open without asking — less friction, but surprising; user may not want their vault in git.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Git is now a first-class prerequisite for Laputa vaults, not just implied by the cache strategy.
|
||||
- The `is_git_repo` command is intentionally lightweight (checks for `.git` existence only; does not validate remote or commit history).
|
||||
- The modal offers `git init` + an initial commit as a one-click path, lowering the barrier for new users.
|
||||
- Browser mode bypasses the check so dev/Storybook workflows are unaffected.
|
||||
- Re-evaluate if Laputa needs to support non-git vaults (e.g., iCloud-only, shared network drive); at that point ADR-0014 would also need revisiting.
|
||||
31
docs/adr/0035-path-suffix-wikilink-resolution.md
Normal file
31
docs/adr/0035-path-suffix-wikilink-resolution.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0035"
|
||||
title: "Path-suffix wikilink resolution for subfolder vaults"
|
||||
status: active
|
||||
date: 2026-04-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0006 stated that wikilink resolution was "simplified to multi-pass title/filename matching — no path-based matching needed" because the vault was flat. ADR-0033 relaxed the flat-vault constraint by adding subfolder scanning. As a result, wikilinks like `[[docs/adr/0031-foo]]` or `[[adr/0031-foo]]` could not resolve to entries in subdirectories: the resolver only matched on `title` and `filename` stem, never on the vault-relative path.
|
||||
|
||||
The backlink detection in the Inspector also used a hardcoded `/Laputa/` path regex, which was wrong for any vault that isn't named "Laputa".
|
||||
|
||||
## Decision
|
||||
|
||||
**Add path-suffix matching as Pass 1 of wikilink resolution: a link target resolves to a `VaultEntry` if the entry's vault-relative path ends with the link string (with or without `.md`). Filename-stem matching (the previous Pass 1) becomes Pass 2. Inspector backlinks replace the hardcoded `/Laputa/` regex with a generic `targetMatchesEntry` path-suffix helper. Autocomplete pre-filter also matches against the full vault-relative path so subfolder names surface results.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Path-suffix as Pass 1, then filename match as Pass 2 — consistent with how Obsidian resolves links in multi-folder vaults, zero config. Downside: if two notes share the same filename in different folders, only the first (path-suffix) match wins.
|
||||
- **Option B**: Strict full-path matching only (disable title-stem resolution) — unambiguous, but breaks the majority of existing short-form `[[note-title]]` links.
|
||||
- **Option C**: Keep title-only matching, require full paths for subfolder notes — backwards-compatible, but forces users to always type full paths for subfolders, defeating the purpose of wikilinks.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Supersedes the "no path-based matching needed" clause from ADR-0006 (that assumption was contingent on the flat vault invariant, which ADR-0033 relaxed).
|
||||
- `relativePathStem` utility added in `wikilink.ts` to extract the vault-relative path stem from a full `VaultEntry`.
|
||||
- The Inspector's `targetMatchesEntry` helper is now the canonical way to test if a link resolves to an entry — use it everywhere instead of ad-hoc regex.
|
||||
- Wikilink autocomplete suggestions now surface notes in subfolders when users type a folder prefix (e.g. `[[adr/`).
|
||||
- Re-evaluate if path-suffix ambiguity (two files with the same name in different folders) becomes a user complaint.
|
||||
31
docs/adr/0036-external-rename-detection-via-git-diff.md
Normal file
31
docs/adr/0036-external-rename-detection-via-git-diff.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0036"
|
||||
title: "External rename detection via git diff on focus regain"
|
||||
status: active
|
||||
date: 2026-04-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Laputa handles in-app renames (rename.rs) and propagates wikilink updates across the vault. But notes can also be renamed externally — from Finder, another editor, or a git operation (e.g., `git mv`). In those cases, the app had no way to detect that a rename had occurred, leaving wikilinks broken and the vault inconsistent.
|
||||
|
||||
The app already uses git for the cache (ADR-0014) and requires git as a vault prerequisite (ADR-0034), making git diff a natural and already-available detection mechanism.
|
||||
|
||||
## Decision
|
||||
|
||||
**When the app window regains focus, run `git diff --diff-filter=R --name-status HEAD` to detect file renames that occurred since the last committed HEAD. If any renamed `.md` files are found, show a non-blocking banner ("X file(s) renamed — update wikilinks?"). Accepting triggers the existing vault-wide wikilink replacement logic (reused from rename.rs). Ignoring dismisses the banner without changes. New Tauri commands: `detect_renames` and `update_wikilinks_for_renames`.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Git diff on focus regain, non-blocking banner — uses existing infrastructure, non-disruptive, user retains control. Downside: only detects renames that are staged/committed; uncommitted renames via `git mv` are captured, but renames done purely in Finder (no git involvement) are not.
|
||||
- **Option B**: `FSEvents` / file-system watcher for rename events — catches all renames regardless of git. Downside: significantly more complex, requires Rust async machinery, false positives from editor temp files, and this feature is already planned as a separate enhancement.
|
||||
- **Option C**: Scan for broken wikilinks on focus — correct but O(n) and noisy; doesn't tell us the new filename.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Git's rename detection (`--diff-filter=R`) requires the rename to be git-tracked (either staged or committed); renames that happen outside git knowledge are not detected by this mechanism.
|
||||
- The on-focus check runs `git diff HEAD` which is fast but adds a small shell invocation overhead each time the window activates. This is acceptable for typical vault sizes.
|
||||
- `rename.rs` is now shared between in-app renames and external rename recovery — the replacement logic is the canonical entry point for wikilink bulk updates.
|
||||
- The banner is non-blocking and "Ignore" is always available — the user never loses work.
|
||||
- Re-evaluate if FS-level rename detection (outside git) becomes a priority; at that point this mechanism would be a fallback, not the primary strategy.
|
||||
31
docs/adr/0037-codemirror-language-markdown-highlighting.md
Normal file
31
docs/adr/0037-codemirror-language-markdown-highlighting.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0037"
|
||||
title: "Language-based markdown syntax highlighting in raw editor"
|
||||
status: active
|
||||
date: 2026-04-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The raw editor (CodeMirror 6, introduced in ADR-0022) initially had a custom `frontmatterHighlight` extension that used regex-based decoration for YAML frontmatter and headings. Markdown body content had no syntax highlighting at all, making the raw editor feel like a plain textarea despite being a full CodeMirror instance.
|
||||
|
||||
Extending the custom regex-based approach to cover all markdown syntax (bold, italic, links, lists, blockquotes, code) would have been brittle and hard to maintain.
|
||||
|
||||
## Decision
|
||||
|
||||
**Replace the custom heading decoration in `frontmatterHighlight.ts` with `@codemirror/lang-markdown` (the official CodeMirror language package). A custom `HighlightStyle` maps CodeMirror highlight tags to visual styles for headings, bold, italic, strikethrough, links, lists, blockquotes, and inline code. The frontmatter YAML plugin is retained for YAML-specific colouring but its heading decoration is removed in favour of the language parser.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): `@codemirror/lang-markdown` with custom HighlightStyle — uses the official, maintained language parser; future highlight rules are one CSS declaration. Downside: adds a new npm dependency; the custom frontmatter plugin must be kept separately.
|
||||
- **Option B**: Extend the custom regex plugin to cover all markdown — no new dependency. Downside: regex-based tokenisation is fragile (e.g., nested formatting), already proving hard to maintain after the heading/frontmatter overlap bug.
|
||||
- **Option C**: Switch to a markdown-aware editor (e.g., Milkdown, Monaco) — full-featured. Downside: major migration, breaks the dual-editor architecture in ADR-0022, significant scope.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `@codemirror/lang-markdown` added to `package.json` — this is the only new runtime dependency introduced by this change.
|
||||
- `frontmatterHighlight.ts` is simplified (heading decoration removed); `markdownHighlight.ts` is the new extension responsible for body highlighting.
|
||||
- The two extensions are composed in `useCodeMirror.ts` — YAML frontmatter block is still styled by the custom plugin; everything else by the language parser.
|
||||
- Future syntax highlighting changes (e.g., task lists, tables) can be added by extending the `HighlightStyle` without modifying the parser.
|
||||
- Re-evaluate if `@codemirror/lang-markdown` conflicts with the custom frontmatter YAML handling as the editor evolves (e.g., if frontmatter block needs to be parsed as a code block rather than decorated text).
|
||||
@@ -89,3 +89,7 @@ proposed → active → superseded
|
||||
| [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active |
|
||||
| [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active |
|
||||
| [0033](0033-subfolder-scanning-and-folder-tree.md) | Subfolder scanning and folder tree navigation | active |
|
||||
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | active |
|
||||
| [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active |
|
||||
| [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active |
|
||||
| [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active |
|
||||
|
||||
@@ -5,26 +5,27 @@ title: "Canary release channel and local feature flags"
|
||||
status: active
|
||||
date: 2026-03-25
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Shipping new features directly to all users is risky. A mechanism was needed to let early adopters test pre-release builds and to gate experimental features behind flags that can be toggled without a new release.
|
||||
|
||||
[[Keyboard-first design principle]]
|
||||
|
||||
## Decision
|
||||
|
||||
**Add a canary release channel alongside stable, with builds from the `canary` branch. Feature flags are localStorage-based (`ff_<name>`) with compile-time defaults, checked via `useFeatureFlag(flag)` hook. The update channel is configurable in Settings.**
|
||||
**Add a canary release channel alongside stable, with builds from the **`canary`** branch. Feature flags are localStorage-based (**`ff_<name>`**) with compile-time defaults, checked via **`useFeatureFlag(flag)`** hook. The update channel is configurable in Settings.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Canary branch + localStorage feature flags — simple, no server infrastructure, users opt in via Settings. Downside: no remote flag management, no gradual rollout percentages.
|
||||
- **Option B**: Server-side feature flags (LaunchDarkly, Unleash) — gradual rollouts, A/B testing. Downside: external dependency, requires server infrastructure, adds latency.
|
||||
- **Option C**: Single release channel with only feature flags — simpler CI. Downside: no way to test full pre-release builds.
|
||||
* **Option A** (chosen): Canary branch + localStorage feature flags — simple, no server infrastructure, users opt in via Settings. Downside: no remote flag management, no gradual rollout percentages.
|
||||
* **Option B**: Server-side feature flags (LaunchDarkly, Unleash) — gradual rollouts, A/B testing. Downside: external dependency, requires server infrastructure, adds latency.
|
||||
* **Option C**: Single release channel with only feature flags — simpler CI. Downside: no way to test full pre-release builds.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `release.yml` builds stable from `main`; `release-canary.yml` builds canary from `canary` branch.
|
||||
- Canary releases produce `latest-canary.json` on GitHub Pages, marked as prerelease.
|
||||
- `useUpdater(channel)` checks the appropriate update manifest.
|
||||
- `useFeatureFlag(flag)` checks localStorage override, then compile-time default. Type-safe via `FeatureFlagName` union.
|
||||
- `update_channel` stored in Settings as `"stable"` or `"canary"`.
|
||||
- Re-evaluation trigger: if user base grows enough to warrant server-side gradual rollouts.
|
||||
* `release.yml` builds stable from `main`; `release-canary.yml` builds canary from `canary` branch.
|
||||
* Canary releases produce `latest-canary.json` on GitHub Pages, marked as prerelease.
|
||||
* `useUpdater(channel)` checks the appropriate update manifest.
|
||||
* `useFeatureFlag(flag)` checks localStorage override, then compile-time default. Type-safe via `FeatureFlagName` union.
|
||||
* `update_channel` stored in Settings as `"stable"` or `"canary"`.
|
||||
* Re-evaluation trigger: if user base grows enough to warrant server-side gradual rollouts.
|
||||
@@ -169,21 +169,17 @@ interface ImmediateCreateDeps {
|
||||
|
||||
/** Create an untitled note without persisting to disk (deferred save). */
|
||||
function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
|
||||
try {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(deps.entries, noteType, deps.pendingNames)
|
||||
deps.pendingNames.add(title)
|
||||
const template = resolveTemplate(deps.entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, deps.vaultPath, template)
|
||||
deps.openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, deps.addEntry)
|
||||
deps.trackUnsaved?.(resolved.entry.path)
|
||||
deps.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => deps.pendingNames.delete(title), 500)
|
||||
} catch (err) {
|
||||
console.error('Failed to create note:', err)
|
||||
}
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(deps.entries, noteType, deps.pendingNames)
|
||||
deps.pendingNames.add(title)
|
||||
const template = resolveTemplate(deps.entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, deps.vaultPath, template)
|
||||
deps.openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, deps.addEntry)
|
||||
deps.trackUnsaved?.(resolved.entry.path)
|
||||
deps.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => deps.pendingNames.delete(title), 500)
|
||||
}
|
||||
|
||||
interface RelationshipCreateDeps {
|
||||
@@ -261,7 +257,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current,
|
||||
openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending,
|
||||
}, type)
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending, setToastMessage])
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending])
|
||||
|
||||
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> => {
|
||||
createNoteForRelationship({
|
||||
|
||||
@@ -128,6 +128,15 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.entries[0].archived).toBe(true)
|
||||
expect(result.current.entries[0].status).toBe('Done')
|
||||
})
|
||||
|
||||
it('preserves entries reference when path does not exist (no-op)', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
const entriesBefore = result.current.entries
|
||||
|
||||
act(() => { result.current.updateEntry('/vault/note/nonexistent.md', { archived: true }) })
|
||||
|
||||
expect(result.current.entries).toBe(entriesBefore)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNoteStatus', () => {
|
||||
|
||||
@@ -133,8 +133,16 @@ export function useVaultLoader(vaultPath: string) {
|
||||
})
|
||||
}, [tracker])
|
||||
|
||||
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) =>
|
||||
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e)), [])
|
||||
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
|
||||
setEntries((prev) => {
|
||||
let changed = false
|
||||
const next = prev.map((e) => {
|
||||
if (e.path === path) { changed = true; return { ...e, ...patch } }
|
||||
return e
|
||||
})
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeEntry = useCallback((path: string) => {
|
||||
setEntries((prev) => prev.filter((e) => e.path !== path))
|
||||
|
||||
39
tests/smoke/create-note-crash.spec.ts
Normal file
39
tests/smoke/create-note-crash.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/** Errors that indicate the app has crashed (not just minor internal warnings). */
|
||||
function isCrashError(msg: string): boolean {
|
||||
return msg.includes('Maximum update depth') || msg.includes('Invalid hook call') || msg.includes('#185')
|
||||
}
|
||||
|
||||
test('create note via Cmd+N does not crash', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => { if (isCrashError(err.message)) errors.push(err.message) })
|
||||
|
||||
await page.goto(process.env.BASE_URL ?? 'http://localhost:5201')
|
||||
await page.waitForSelector('[data-testid="sidebar-top-nav"]', { timeout: 10000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await page.keyboard.press('Meta+n')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
expect(errors).toHaveLength(0)
|
||||
await expect(page.locator('[data-testid="title-field-input"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('create note via sidebar + button does not crash', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => { if (isCrashError(err.message)) errors.push(err.message) })
|
||||
|
||||
await page.goto(process.env.BASE_URL ?? 'http://localhost:5201')
|
||||
await page.waitForSelector('[data-testid="sidebar-top-nav"]', { timeout: 10000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const plusButtons = page.locator('button[aria-label*="Create new"]')
|
||||
if (await plusButtons.count() > 0) {
|
||||
await plusButtons.first().click()
|
||||
await page.waitForTimeout(2000)
|
||||
}
|
||||
|
||||
expect(errors).toHaveLength(0)
|
||||
await expect(page.locator('[data-testid="title-field-input"]')).toBeVisible()
|
||||
})
|
||||
Reference in New Issue
Block a user