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
35 changes: 21 additions & 14 deletions src/lib/components/DocumentsView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -186,41 +186,49 @@
if (childrenCache.has(docId)) return;
try {
const children = await docs.list({ parent: docId });
childrenCache.set(docId, children);
childrenCache = childrenCache;
// Immutable update — Svelte 5 does not re-render {@const} reads when a
// $state Map is mutated in place + reassigned to the same ref.
const next = new Map(childrenCache);
next.set(docId, children);
childrenCache = next;
} catch (e: any) {
showError(`Failed to load children: ${e}`);
}
}

// ─── Load all descendants (for selected doc's tree path) ─────────
async function expandPathToDoc(docId: string) {
// Children are pre-built; just walk the cached tree and expand the path.
// Children are pre-built; walk the cached tree and expand the path.
// Collect into a new Set and reassign once — Svelte 5 needs a new ref
// to re-render {@const} reads of a $state Set.
const next = new Set(expandedIds);
function searchLevel(entries: DocEntry[]): boolean {
for (const entry of entries) {
if (entry.id === docId) return true;
if (hasKids(entry)) {
const kids = childrenCache.get(entry.id) ?? [];
if (!expandedIds.has(entry.id)) {
expandedIds.add(entry.id);
expandedIds = expandedIds;
}
next.add(entry.id);
if (searchLevel(kids)) return true;
}
}
return false;
}
searchLevel(rootDocs);
expandedIds = next;
}

// ─── Toggle tree node ─────────────────────────────────────────────
async function toggleNode(doc: DocEntry) {
if (expandedIds.has(doc.id)) {
expandedIds.delete(doc.id);
expandedIds = expandedIds;
// Immutable update (new Set) — required for Svelte 5 to re-render the
// {@const expanded = expandedIds.has(...)} reads in the tree snippet.
// Mutating in place + reassigning the same ref does NOT trigger updates.
const next = new Set(expandedIds);
if (next.has(doc.id)) {
next.delete(doc.id);
expandedIds = next;
} else {
expandedIds.add(doc.id);
expandedIds = expandedIds;
next.add(doc.id);
expandedIds = next;
// Children are pre-built in loadRootDocs(); load lazily only if missing.
if (!childrenCache.has(doc.id) && doc.has_children) {
await loadChildren(doc.id);
Expand All @@ -245,8 +253,7 @@
viewMode = 'preview';
// Auto-expand this document's subtree (children pre-built in loadRootDocs).
if (hasKids(full)) {
expandedIds.add(full.id);
expandedIds = expandedIds;
expandedIds = new Set([...expandedIds, full.id]);
}
// Auto-expand tree path to this document
await expandPathToDoc(full.id);
Expand Down
30 changes: 30 additions & 0 deletions src/lib/components/__tests__/TreeRepro.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script lang="ts">
let expanded = $state<Set<string>>(new Set(['a']));
let kids: Record<string, string[]> = { a: ['a1', 'a2'] };

export function toggle(id: string) {
const next = new Set(expanded);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
expanded = next;
}

export function isOpen(id: string) {
return expanded.has(id);
}
</script>

{#snippet node(id: string)}
{@const open = expanded.has(id)}
<button data-testid="toggle-{id}" onclick={() => toggle(id)}>tog</button>
{#if open}
<ul data-testid="kids-{id}">
{#each kids[id] ?? [] as c}{@render node(c)}{/each}
</ul>
{/if}
{/snippet}

{#each Object.keys(kids) as id}{@render node(id)}{/each}
15 changes: 15 additions & 0 deletions src/lib/components/__tests__/set-reactivity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { render, fireEvent } from '@testing-library/svelte';
import TreeRepro from './TreeRepro.svelte';

describe('doc-tree toggle: $state Set needs immutable update', () => {
it('toggles open/closed on click', async () => {
const { getByTestId, queryByTestId } = render(TreeRepro);
// starts expanded
expect(queryByTestId('kids-a')).toBeTruthy();
await fireEvent.click(getByTestId('toggle-a'));
expect(queryByTestId('kids-a')).toBeNull();
await fireEvent.click(getByTestId('toggle-a'));
expect(queryByTestId('kids-a')).toBeTruthy();
});
});
Loading