Merge pull request #625 from AtalayaLabs/claude/performance-optimization-analysis-raoezl
Round 16: incremental lanes/contextMap builders & alloc cuts
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* O(1) append detection shared by the incremental grouped-list builders
|
||||
* (`resourceSections`'s `ResourceSectionsBuilder`, `sharedLanes`'s
|
||||
* `SharedLanesBuilder`): true iff `next` is a strict prefix extension of
|
||||
* `prev` — strictly longer, and sharing prev's boundary element by identity.
|
||||
*
|
||||
* Both builders use it to choose between their O(N) incremental `extend` and a
|
||||
* full rebuild. The accumulated lists they guard are only ever mutated by
|
||||
* appending a page (infinite scroll: `raw = [...raw, ...page]`) or replaced by
|
||||
* a filtered copy that preserves element identity — so a matching boundary
|
||||
* object is a sound witness that only fresh items were appended. Any other
|
||||
* change (deletion, filter toggle, reorder) fails the boundary check and falls
|
||||
* back to a rebuild, keeping the output byte-for-byte equal to a full pass.
|
||||
*/
|
||||
export function isAppendExtension<T>(prev: readonly T[], next: readonly T[]): boolean {
|
||||
if (next.length <= prev.length) return false;
|
||||
// Prefix identity via the boundary object — O(1). If the element that used
|
||||
// to be last is still at that index, the prefix was untouched and next just
|
||||
// grew at the tail.
|
||||
return prev.length === 0 || next[prev.length - 1] === prev[prev.length - 1];
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { primeContextPage } from './listContext';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the incremental route-level `contextMap` maintenance
|
||||
* (primeContextPage) that replaced `contextMap = $derived(new Map(raw.map(...)))`
|
||||
* on the trash / recent / favorites / shared-with-me routes.
|
||||
*
|
||||
* Audit finding (ROUND16 §F2, the route-level half of the class ROUND15 §F1
|
||||
* fixed inside ResourceList): each route paged its rows in via
|
||||
* `raw = [...raw, ...page.items]` and rebuilt a brand-new Map — hashing every
|
||||
* accumulated id — on EVERY page. O(N) per page ⇒ Σ O(N²/page) across a drain,
|
||||
* plus a fresh Map instance each page. The fix holds one persistent map and
|
||||
* sets only the fresh page's entries (mirrors the shipped `favoriteIds`
|
||||
* SvelteSet, ROUND14 §F2).
|
||||
*
|
||||
* Gates (rollback rule: an AFTER that fails to beat its BEFORE fails CI):
|
||||
* 1. Equivalence — at EVERY page, the incrementally-primed map is deep-equal
|
||||
* to a full `new Map(cumulative.map(entry))` rebuild, including skipped
|
||||
* entries (drives → null) and the reset path.
|
||||
* 2. Perf — `entry` work collapses from Σ O(N²/page) to O(N) across the drain
|
||||
* (deterministic call count) and wall drops ≥3x.
|
||||
*/
|
||||
|
||||
interface Ctx {
|
||||
date: string | null;
|
||||
ownerId: string | null;
|
||||
}
|
||||
interface Raw {
|
||||
resource: { id: string; updated_by: string | null };
|
||||
resource_type: 'file' | 'folder' | 'drive';
|
||||
accessed_at: string;
|
||||
}
|
||||
|
||||
const pad = (i: number) => i.toString().padStart(6, '0');
|
||||
|
||||
/** Item `i`; every 10th is a `drive` (skipped by the shared-with-me-style entry). */
|
||||
function raw(i: number): Raw {
|
||||
return {
|
||||
resource: { id: `res-${pad(i)}`, updated_by: `user-${i % 8}` },
|
||||
resource_type: i % 10 === 0 ? 'drive' : i % 3 === 0 ? 'folder' : 'file',
|
||||
accessed_at: `2026-07-${pad((i % 27) + 1).slice(-2)}`
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps a raw item to its `[id, ctx]`, skipping drives (returns null) — counts calls. */
|
||||
function makeEntry(counter?: { n: number }): (it: Raw) => readonly [string, Ctx] | null {
|
||||
return (it) => {
|
||||
if (counter) counter.n++;
|
||||
if (it.resource_type === 'drive') return null;
|
||||
return [it.resource.id, { date: it.accessed_at, ownerId: it.resource.updated_by }];
|
||||
};
|
||||
}
|
||||
|
||||
/** Verbatim BEFORE: the old derive — a fresh Map hashing the whole cumulative list. */
|
||||
function rebuild(
|
||||
cumulative: Raw[],
|
||||
entry: (it: Raw) => readonly [string, Ctx] | null
|
||||
): Map<string, Ctx> {
|
||||
const m = new Map<string, Ctx>();
|
||||
for (const it of cumulative) {
|
||||
const e = entry(it);
|
||||
if (e !== null) m.set(e[0], e[1]);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
const PAGE = 50;
|
||||
const PAGES = 50; // 2 500-item drain
|
||||
|
||||
describe('incremental contextMap (benchmark gate)', () => {
|
||||
it('stays deep-equal to the full rebuild at every page (incl. skipped drives)', () => {
|
||||
const all = Array.from({ length: PAGE * PAGES }, (_, i) => raw(i));
|
||||
const entry = makeEntry();
|
||||
const map = new Map<string, Ctx>();
|
||||
for (let p = 1; p <= PAGES; p++) {
|
||||
const page = all.slice((p - 1) * PAGE, p * PAGE);
|
||||
primeContextPage(map, p === 1, page, entry);
|
||||
const reference = rebuild(all.slice(0, p * PAGE), entry);
|
||||
expect(new Map(map), `page ${p}`).toEqual(reference);
|
||||
}
|
||||
});
|
||||
|
||||
it('clears on reset and re-primes to the reset page only', () => {
|
||||
const all = Array.from({ length: 200 }, (_, i) => raw(i));
|
||||
const entry = makeEntry();
|
||||
const map = new Map<string, Ctx>();
|
||||
primeContextPage(map, true, all.slice(0, 100), entry);
|
||||
primeContextPage(map, false, all.slice(100, 150), entry);
|
||||
// Reset with a disjoint page: prior ids must be gone.
|
||||
const resetPage = all.slice(150, 200);
|
||||
primeContextPage(map, true, resetPage, entry);
|
||||
expect(new Map(map)).toEqual(rebuild(resetPage, entry));
|
||||
});
|
||||
|
||||
it('collapses entry work from Σ O(N²/page) to O(N) and runs ≥3x faster', () => {
|
||||
const N = PAGE * PAGES;
|
||||
const all = Array.from({ length: N }, (_, i) => raw(i));
|
||||
|
||||
// Deterministic call-count gate (the hard rollback gate): incremental
|
||||
// computes each item's entry exactly once; the rebuild is quadratic. This
|
||||
// holds regardless of machine load.
|
||||
const afterCounter = { n: 0 };
|
||||
const afterEntry = makeEntry(afterCounter);
|
||||
const countMap = new Map<string, Ctx>();
|
||||
for (let p = 1; p <= PAGES; p++) {
|
||||
primeContextPage(countMap, p === 1, all.slice((p - 1) * PAGE, p * PAGE), afterEntry);
|
||||
}
|
||||
const beforeCounter = { n: 0 };
|
||||
const beforeEntry = makeEntry(beforeCounter);
|
||||
for (let p = 1; p <= PAGES; p++) rebuild(all.slice(0, p * PAGE), beforeEntry);
|
||||
expect(afterCounter.n).toBe(N);
|
||||
expect(beforeCounter.n).toBe((PAGES * (PAGES + 1) * PAGE) / 2);
|
||||
expect(afterCounter.n).toBeLessThan(beforeCounter.n / 5);
|
||||
|
||||
// Wall gate — best-of-3 (min) per arm to shrug off scheduler / GC noise
|
||||
// under a saturated test runner (mirrors round14 §F1's `Math.min` pattern).
|
||||
const entry = makeEntry();
|
||||
const runAfter = () => {
|
||||
const m = new Map<string, Ctx>();
|
||||
const t = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) {
|
||||
primeContextPage(m, p === 1, all.slice((p - 1) * PAGE, p * PAGE), entry);
|
||||
}
|
||||
return performance.now() - t;
|
||||
};
|
||||
const runBefore = () => {
|
||||
const t = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) rebuild(all.slice(0, p * PAGE), entry);
|
||||
return performance.now() - t;
|
||||
};
|
||||
const afterMs = Math.min(runAfter(), runAfter(), runAfter());
|
||||
const beforeMs = Math.min(runBefore(), runBefore(), runBefore());
|
||||
|
||||
console.info(
|
||||
`contextMap ${PAGES}×${PAGE}: before ${beforeCounter.n} entry calls / ${beforeMs.toFixed(1)} ms — after ${afterCounter.n} calls / ${afterMs.toFixed(1)} ms (${(beforeCounter.n / afterCounter.n).toFixed(1)}x fewer calls, ${(beforeMs / afterMs).toFixed(1)}x wall)`
|
||||
);
|
||||
|
||||
expect(afterMs).toBeLessThan(beforeMs / 3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Incremental maintenance of a grouped-listing route's per-item `contextMap`
|
||||
* (the `id → ItemContext` envelope `ResourceList` reads via `ctxOf`).
|
||||
*
|
||||
* The trash / recent / favorites / shared-with-me routes page their rows in via
|
||||
* infinite scroll (`raw = [...raw, ...page.items]`) and each derived its
|
||||
* contextMap as `new Map(raw.map((it) => [id, ctx]))` — rebuilding a brand-new
|
||||
* Map, hashing every accumulated id, on EVERY page. O(N) per page ⇒ O(N²)
|
||||
* across a drain, and a fresh Map instance each page invalidated every reader
|
||||
* (ROUND15 landed the `sections` half of this class inside `ResourceList` but
|
||||
* left the route-level projection that feeds it untouched).
|
||||
*
|
||||
* {@link primeContextPage} mirrors the shipped `favoriteIds` fix (ROUND14 §F2,
|
||||
* `SvelteSet` primed per page): the route holds ONE persistent reactive map
|
||||
* (`SvelteMap`) for the component's lifetime and, in `load()`, clears it on a
|
||||
* reset and sets only the freshly-fetched page's entries — O(page) per page,
|
||||
* O(N) across the drain, one stable instance. The map only ever needs to be a
|
||||
* superset of the currently-displayed ids: rows removed by a delete are no
|
||||
* longer rendered, so their now-stale entries are never read (identical
|
||||
* reasoning to `favoriteIds`). Every id entering `raw` comes through a
|
||||
* `load()` page, so the map always covers what is on screen.
|
||||
*
|
||||
* The param is typed `Map` (not `SvelteMap`) so the benchmark can drive the
|
||||
* exact same update logic against a plain Map, decoupled from Svelte
|
||||
* reactivity — the same way `round14.bench.test.ts` benches the `favoriteIds`
|
||||
* set. Callers pass their `SvelteMap` at runtime.
|
||||
*/
|
||||
export function primeContextPage<Raw, C>(
|
||||
map: Map<string, C>,
|
||||
reset: boolean,
|
||||
page: Iterable<Raw>,
|
||||
/** Map one fetched item to its `[id, ctx]` entry, or `null` to skip it (e.g. drives). */
|
||||
entry: (item: Raw) => readonly [string, C] | null
|
||||
): void {
|
||||
if (reset) map.clear();
|
||||
for (const item of page) {
|
||||
const e = entry(item);
|
||||
if (e !== null) map.set(e[0], e[1]);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@
|
||||
* gate asserts the incremental builder stays deep-equal to it at every page.
|
||||
*/
|
||||
|
||||
import { isAppendExtension } from './appendExtension';
|
||||
|
||||
/** One swimlane: a bucket key, its (possibly async-resolved) header label, and its rows. */
|
||||
export interface ResourceSection<T> {
|
||||
key: string;
|
||||
@@ -107,15 +109,6 @@ export class ResourceSectionsBuilder<T, C> {
|
||||
/** False until a grouped sync has populated the accumulation state. */
|
||||
#grouped = false;
|
||||
|
||||
/** Whether `next` extends `prev` (same prefix objects + strictly longer). */
|
||||
#isAppend(prev: T[], next: T[]): boolean {
|
||||
if (next.length <= prev.length) return false;
|
||||
// Prefix identity via the boundary object — O(1); the list is only ever
|
||||
// mutated by appending a page or by replacing it with a filtered copy
|
||||
// (which preserves element identity).
|
||||
return prev.length === 0 || next[prev.length - 1] === prev[prev.length - 1];
|
||||
}
|
||||
|
||||
#rebuild(items: T[], grouping: SectionGrouping<T, C>): void {
|
||||
const bucketOf = grouping.bucketOf!;
|
||||
this.#order = [];
|
||||
@@ -172,7 +165,7 @@ export class ResourceSectionsBuilder<T, C> {
|
||||
if (
|
||||
this.#grouped &&
|
||||
this.#bucketOf === grouping.bucketOf &&
|
||||
this.#isAppend(this.#items, items)
|
||||
isAppendExtension(this.#items, items)
|
||||
) {
|
||||
this.#extend(items, grouping);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SharedLanesBuilder, buildLanes, type Lane, type LaneGrouping } from './sharedLanes';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the incremental lanes builder (SharedLanesBuilder) that
|
||||
* replaced the `lanes` `$derived.by` on the "My shares" page
|
||||
* (shared/+page.svelte).
|
||||
*
|
||||
* Audit finding (ROUND15 deferred): the shares page pages its outgoing grants
|
||||
* in via `raw = [...raw, ...page.items]`, and `lanes` re-bucketed the WHOLE
|
||||
* accumulated (filtered) list on every page — and on every grant edit —
|
||||
* allocating a fresh lane object + a fresh `rows` array for every lane each
|
||||
* time. Σ ≈ O(N²/page) `emit` calls during an infinite-scroll drain. Same
|
||||
* class as the F1 flagship (ResourceList.sections), but the lanes shape fans
|
||||
* one item out to many rows across many lanes and caches a header at first
|
||||
* appearance — see sharedLanes.ts.
|
||||
*
|
||||
* Gates (rollback rule: an AFTER that fails to beat its BEFORE fails CI):
|
||||
* 1. Equivalence — at EVERY page of the drain, the incremental output is
|
||||
* deep-equal to the verbatim full-rebuild reference (buildLanes), for the
|
||||
* by-files group-by (1 lane per resource, contiguous) AND the by-subject
|
||||
* group-by (a resource's grants scatter across subject lanes, so a fresh
|
||||
* page sprays rows into already-emitted lanes — non-contiguous).
|
||||
* 2. Reference stability — untouched lanes keep their exact `rows` array
|
||||
* reference across a page append; a grown lane gets a fresh one.
|
||||
* 3. Fallback — group-by switch, grant edit / deletion and kind-filter toggle
|
||||
* fall back to a correct full rebuild.
|
||||
* 4. Perf — `emit` work collapses from Σ O(N²/page) to O(N) across the drain
|
||||
* (deterministic call count) and wall drops ≥3x.
|
||||
*/
|
||||
|
||||
interface Grant {
|
||||
grant_id: string;
|
||||
subject_type: 'user' | 'group' | 'link';
|
||||
subject_id: string;
|
||||
has_password: boolean;
|
||||
}
|
||||
interface Item {
|
||||
resource: { id: string; name: string };
|
||||
grants: Grant[];
|
||||
}
|
||||
type Header =
|
||||
| { kind: 'resource'; item: Item }
|
||||
| { kind: 'user'; id: string }
|
||||
| { kind: 'group'; id: string }
|
||||
| { kind: 'linkPublic' }
|
||||
| { kind: 'linkPassword' };
|
||||
type Row = { grant: Grant; item: Item };
|
||||
|
||||
const pad = (i: number) => i.toString().padStart(6, '0');
|
||||
|
||||
/**
|
||||
* Item `i` with 3 grants: two user grants whose subject round-robins across a
|
||||
* small pool (so by-subject buckets repeat across items → non-contiguous), and
|
||||
* one link grant (public / password alternating). Mirrors the shape the shares
|
||||
* endpoint returns.
|
||||
*/
|
||||
function item(i: number): Item {
|
||||
return {
|
||||
resource: { id: `res-${pad(i)}`, name: `file-${pad(i)}` },
|
||||
grants: [
|
||||
{
|
||||
grant_id: `g-${pad(i)}-0`,
|
||||
subject_type: 'user',
|
||||
subject_id: `user-${i % 8}`,
|
||||
has_password: false
|
||||
},
|
||||
{
|
||||
grant_id: `g-${pad(i)}-1`,
|
||||
subject_type: 'group',
|
||||
subject_id: `group-${i % 5}`,
|
||||
has_password: false
|
||||
},
|
||||
{
|
||||
grant_id: `g-${pad(i)}-2`,
|
||||
subject_type: 'link',
|
||||
subject_id: '',
|
||||
has_password: i % 2 === 0
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/** By-files group-by: one lane per resource; the old derive's unconditional `ensure`. */
|
||||
function itemsGrouping(counter?: { n: number }): LaneGrouping<Item, Header, Row> {
|
||||
return {
|
||||
groupKey: 'items',
|
||||
emit: (it, sink) => {
|
||||
if (counter) counter.n++;
|
||||
const key = `resource:${it.resource.id}`;
|
||||
const header: Header = { kind: 'resource', item: it };
|
||||
sink.open(key, header);
|
||||
for (const grant of it.grants) sink.push(key, header, { grant, item: it });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** By-subject group-by: a resource's grants scatter across one lane per subject / link kind. */
|
||||
function sharedWithGrouping(counter?: { n: number }): LaneGrouping<Item, Header, Row> {
|
||||
return {
|
||||
groupKey: 'sharedWith',
|
||||
emit: (it, sink) => {
|
||||
if (counter) counter.n++;
|
||||
for (const grant of it.grants) {
|
||||
let key: string;
|
||||
let header: Header;
|
||||
if (grant.subject_type === 'user') {
|
||||
key = `user:${grant.subject_id}`;
|
||||
header = { kind: 'user', id: grant.subject_id };
|
||||
} else if (grant.subject_type === 'group') {
|
||||
key = `group:${grant.subject_id}`;
|
||||
header = { kind: 'group', id: grant.subject_id };
|
||||
} else if (grant.has_password) {
|
||||
key = 'links:password';
|
||||
header = { kind: 'linkPassword' };
|
||||
} else {
|
||||
key = 'links:public';
|
||||
header = { kind: 'linkPublic' };
|
||||
}
|
||||
sink.push(key, header, { grant, item: it });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const PAGE = 50;
|
||||
const PAGES = 50; // 2 500-item drain
|
||||
|
||||
describe('incremental shared lanes (benchmark gate)', () => {
|
||||
for (const [name, mk] of [
|
||||
['by-files (contiguous, 1 lane/resource)', itemsGrouping],
|
||||
['by-subject (non-contiguous fan-out)', sharedWithGrouping]
|
||||
] as const) {
|
||||
it(`stays deep-equal to the full rebuild at every page — ${name}`, () => {
|
||||
const all = Array.from({ length: PAGE * PAGES }, (_, i) => item(i));
|
||||
const builder = new SharedLanesBuilder<Item, Header, Row>();
|
||||
const g = mk();
|
||||
for (let p = 1; p <= PAGES; p++) {
|
||||
const cumulative = all.slice(0, p * PAGE);
|
||||
const incremental = builder.sync(cumulative, g);
|
||||
const reference = buildLanes(cumulative, g);
|
||||
expect(incremental, `page ${p}`).toEqual(reference);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('keeps untouched lane arrays reference-stable and refreshes grown ones', () => {
|
||||
// A grouping that yields both stable and grown lanes on append: each item
|
||||
// contributes to a per-block lane (block = ⌊i/40⌋, so older blocks are
|
||||
// untouched by a later page) AND a single global lane (grows every page).
|
||||
const grouping: LaneGrouping<Item, Header, Row> = {
|
||||
groupKey: 'blocks',
|
||||
emit: (it, sink) => {
|
||||
const i = Number(it.resource.id.slice(4));
|
||||
const blockKey = `block:${Math.floor(i / 40)}`;
|
||||
sink.push(blockKey, { kind: 'user', id: blockKey }, { grant: it.grants[0], item: it });
|
||||
sink.push('all', { kind: 'user', id: 'all' }, { grant: it.grants[1], item: it });
|
||||
}
|
||||
};
|
||||
const all = Array.from({ length: 200 }, (_, i) => item(i));
|
||||
const builder = new SharedLanesBuilder<Item, Header, Row>();
|
||||
|
||||
const first = builder.sync(all.slice(0, 120), grouping);
|
||||
const refBefore = new Map(first.map((l) => [l.key, l.rows]));
|
||||
|
||||
const second = builder.sync(all.slice(0, 160), grouping);
|
||||
const refAfter = new Map(second.map((l) => [l.key, l.rows]));
|
||||
|
||||
// Old blocks (0,1,2 = items 0..119) are untouched → same array reference.
|
||||
expect(refAfter.get('block:0')).toBe(refBefore.get('block:0'));
|
||||
expect(refAfter.get('block:2')).toBe(refBefore.get('block:2'));
|
||||
// The global lane grew → a fresh reference (a keyed {#each} re-renders it).
|
||||
expect(refAfter.get('all')).not.toBe(refBefore.get('all'));
|
||||
// And a brand-new block appeared for items 120..159.
|
||||
expect(refBefore.has('block:3')).toBe(false);
|
||||
expect(refAfter.has('block:3')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to a correct full rebuild on group-by switch, edit and filter toggle', () => {
|
||||
const all = Array.from({ length: 300 }, (_, i) => item(i));
|
||||
const builder = new SharedLanesBuilder<Item, Header, Row>();
|
||||
const byItems = itemsGrouping();
|
||||
const bySubject = sharedWithGrouping();
|
||||
|
||||
// Drain a few pages by-files, then switch to by-subject (groupKey change → rebuild).
|
||||
builder.sync(all.slice(0, 150), byItems);
|
||||
builder.sync(all.slice(0, 300), byItems);
|
||||
expect(builder.sync(all.slice(0, 300), bySubject)).toEqual(
|
||||
buildLanes(all.slice(0, 300), bySubject)
|
||||
);
|
||||
|
||||
// Grant edit under the SAME group-by: an item's grants change but the item
|
||||
// list length is unchanged → not a strict append → rebuild. Mutate a copy.
|
||||
const edited = all
|
||||
.slice(0, 300)
|
||||
.map((it, i) => (i === 10 ? { ...it, grants: it.grants.slice(0, 1) } : it));
|
||||
expect(builder.sync(edited, bySubject)).toEqual(buildLanes(edited, bySubject));
|
||||
|
||||
// Deletion (list shrinks) → rebuild.
|
||||
const shrunk = edited.filter((_, i) => i % 9 !== 0);
|
||||
expect(builder.sync(shrunk, bySubject)).toEqual(buildLanes(shrunk, bySubject));
|
||||
|
||||
// Kind-filter toggle: the filtered list becomes a different (reordered)
|
||||
// subset → boundary mismatch → rebuild, still equal to a full pass.
|
||||
const filtered = all.slice(0, 300).filter((_, i) => i % 3 === 0);
|
||||
expect(builder.sync(filtered, byItems)).toEqual(buildLanes(filtered, byItems));
|
||||
});
|
||||
|
||||
it('collapses emit work from Σ O(N²/page) to O(N) and runs ≥3x faster', () => {
|
||||
const N = PAGE * PAGES;
|
||||
const all = Array.from({ length: N }, (_, i) => item(i));
|
||||
|
||||
// Deterministic call-count gate (the hard rollback gate): the incremental
|
||||
// builder emits each item exactly once across the drain; the full rebuild
|
||||
// is quadratic (Σ_{p=1..P} p·PAGE). This is noise-free — it holds regardless
|
||||
// of machine load.
|
||||
const afterCounter = { n: 0 };
|
||||
const gAfterCount = sharedWithGrouping(afterCounter);
|
||||
const countBuilder = new SharedLanesBuilder<Item, Header, Row>();
|
||||
for (let p = 1; p <= PAGES; p++) countBuilder.sync(all.slice(0, p * PAGE), gAfterCount);
|
||||
const beforeCounter = { n: 0 };
|
||||
const gBeforeCount = sharedWithGrouping(beforeCounter);
|
||||
for (let p = 1; p <= PAGES; p++) buildLanes(all.slice(0, p * PAGE), gBeforeCount);
|
||||
expect(afterCounter.n).toBe(N);
|
||||
expect(beforeCounter.n).toBe((PAGES * (PAGES + 1) * PAGE) / 2);
|
||||
expect(afterCounter.n).toBeLessThan(beforeCounter.n / 5);
|
||||
|
||||
// Wall gate — best-of-3 (min) per arm to shrug off scheduler / GC noise
|
||||
// under a saturated test runner (mirrors round14 §F1's `Math.min` pattern);
|
||||
// the tiny incremental arm is otherwise vulnerable to a single GC pause.
|
||||
// The O(N²)→O(N) collapse leaves ample headroom over the 3x floor.
|
||||
const runAfter = () => {
|
||||
const b = new SharedLanesBuilder<Item, Header, Row>();
|
||||
const g = sharedWithGrouping();
|
||||
const t = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) b.sync(all.slice(0, p * PAGE), g);
|
||||
return performance.now() - t;
|
||||
};
|
||||
const runBefore = () => {
|
||||
const g = sharedWithGrouping();
|
||||
const t = performance.now();
|
||||
for (let p = 1; p <= PAGES; p++) buildLanes(all.slice(0, p * PAGE), g);
|
||||
return performance.now() - t;
|
||||
};
|
||||
const afterMs = Math.min(runAfter(), runAfter(), runAfter());
|
||||
const beforeMs = Math.min(runBefore(), runBefore(), runBefore());
|
||||
|
||||
console.info(
|
||||
`shared lanes ${PAGES}×${PAGE}: before ${beforeCounter.n} emit calls / ${beforeMs.toFixed(1)} ms — after ${afterCounter.n} calls / ${afterMs.toFixed(1)} ms (${(beforeCounter.n / afterCounter.n).toFixed(1)}x fewer calls, ${(beforeMs / afterMs).toFixed(1)}x wall)`
|
||||
);
|
||||
|
||||
expect(afterMs).toBeLessThan(beforeMs / 3);
|
||||
});
|
||||
});
|
||||
|
||||
// Keep the exported types referenced so a stray unused-import lint can't creep in.
|
||||
export type { Lane };
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Incremental swimlane builder for the "My shares" page (`shared/+page.svelte`).
|
||||
*
|
||||
* The ROUND15-deferred follow-up to the F1 flagship (`resourceSections.ts`):
|
||||
* that fix replaced `ResourceList`'s `sections` derive; this one replaces the
|
||||
* `lanes` `$derived.by` on the shares page, which had the same O(N²/page)
|
||||
* shape. The page pages its outgoing grants in via infinite scroll
|
||||
* (`raw = [...raw, ...page.items]`), and `lanes` re-bucketed the WHOLE
|
||||
* accumulated (filtered) list on every page — and on every grant edit —
|
||||
* allocating a brand-new lane object and a brand-new `rows` array for every
|
||||
* lane each time. Σ ≈ O(N²/page) `emit` calls across an infinite-scroll drain.
|
||||
*
|
||||
* The lanes shape differs from `resourceSections` in two ways, so it gets its
|
||||
* own builder rather than reusing `ResourceSectionsBuilder` (only the O(1)
|
||||
* append test is genuinely shared — see {@link isAppendExtension}):
|
||||
*
|
||||
* - **Fan-out.** One input item contributes 0..N rows across 0..M lanes (in
|
||||
* the "shared with" group-by a resource's grants scatter across one lane per
|
||||
* distinct subject), whereas a resource section maps one item to exactly one
|
||||
* bucket with the item itself as the row.
|
||||
* - **Header captured at first appearance.** A lane's header (a tagged union
|
||||
* identifying the resource / subject / link kind) is fixed by the lane's
|
||||
* first-seen member and never recomputed — unlike a section's `label`, which
|
||||
* is recomputed every sync because it resolves async. (The shares page mirrors
|
||||
* that: it renders the header's *label* live via `resolveLabel(...)` at render
|
||||
* time from the stable header, so only the header identity is cached here.)
|
||||
*
|
||||
* Correctness does not depend on lane contiguity in server order. The
|
||||
* "shared with" group-by is non-monotonic — a fresh page sprays rows across
|
||||
* already-emitted subject lanes — exactly like F1's "trash by drive" case, and
|
||||
* stays byte-for-byte equal to a full rebuild (it just refreshes more lanes per
|
||||
* page). The pure {@link buildLanes} is the verbatim reference (what the old
|
||||
* `lanes` derive produced); the benchmark gate holds the incremental builder
|
||||
* deep-equal to it at every page.
|
||||
*/
|
||||
|
||||
import { isAppendExtension } from './appendExtension';
|
||||
|
||||
/** One swimlane: a stable key, its first-appearance header, and its rows. */
|
||||
export interface Lane<H, R> {
|
||||
key: string;
|
||||
header: H;
|
||||
rows: R[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sink an item's {@link LaneGrouping.emit} writes its contributions to.
|
||||
* `open` ensures a lane exists (0 rows is valid — mirrors the old derive's
|
||||
* unconditional `ensure(...)` in the by-files group-by); `push` ensures the
|
||||
* lane and appends a row. The `header` is consulted only when the key is first
|
||||
* seen.
|
||||
*/
|
||||
export interface LaneSink<H, R> {
|
||||
open(key: string, header: H): void;
|
||||
push(key: string, header: H, row: R): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The grouping the builder needs: a `groupKey` identity (a change forces a full
|
||||
* rebuild) and an `emit` that maps one item to its lane contributions via the
|
||||
* {@link LaneSink}. Generic over item `T`, header `H` and row `R` so the module
|
||||
* stays independent of the shares page's concrete types.
|
||||
*/
|
||||
export interface LaneGrouping<T, H, R> {
|
||||
/** Identity of the active grouping; a change between syncs forces a rebuild. */
|
||||
groupKey: string;
|
||||
/** Emit an item's lane contributions, in order, into `sink`. */
|
||||
emit: (item: T, sink: LaneSink<H, R>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim reference: the `Lane[]` the old `lanes` `$derived.by` produced for
|
||||
* `items` under `grouping`. Lane order is first-appearance; within a lane, row
|
||||
* order is (item, then emit) order. The benchmark gate holds the incremental
|
||||
* builder equal to this at every page.
|
||||
*/
|
||||
export function buildLanes<T, H, R>(items: T[], grouping: LaneGrouping<T, H, R>): Lane<H, R>[] {
|
||||
const out: Lane<H, R>[] = [];
|
||||
const byKey = new Map<string, Lane<H, R>>();
|
||||
const ensure = (key: string, header: H): Lane<H, R> => {
|
||||
let lane = byKey.get(key);
|
||||
if (lane === undefined) {
|
||||
lane = { key, header, rows: [] };
|
||||
byKey.set(key, lane);
|
||||
out.push(lane);
|
||||
}
|
||||
return lane;
|
||||
};
|
||||
const sink: LaneSink<H, R> = {
|
||||
open: (key, header) => void ensure(key, header),
|
||||
push: (key, header, row) => ensure(key, header).rows.push(row)
|
||||
};
|
||||
for (const item of items) grouping.emit(item, sink);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental lanes builder. Call {@link sync} with the current (already
|
||||
* kind-filtered) item list and grouping on every change; it detects the common
|
||||
* case — the list grew by appending a page under an unchanged group-by — and
|
||||
* re-emits only the fresh items, appending to the touched lanes (each of which
|
||||
* gets a fresh `rows` array so a keyed `{#each}` re-renders it) while every
|
||||
* untouched lane keeps its exact array reference. Any other change (group-by
|
||||
* switch, grant edit / deletion, kind-filter toggle, non-append) falls back to
|
||||
* a full rebuild, so the result is always deep-equal to {@link buildLanes}.
|
||||
*/
|
||||
export class SharedLanesBuilder<T, H, R> {
|
||||
/** Last synced list — the append cursor and the append-detection baseline. */
|
||||
#items: T[] = [];
|
||||
/** Lane keys in first-appearance order. */
|
||||
#order: string[] = [];
|
||||
/** key → the lane's first-appearance header. */
|
||||
#headers = new Map<string, H>();
|
||||
/** key → the lane's rows array (a fresh reference whenever it grows). */
|
||||
#rows = new Map<string, R[]>();
|
||||
/** The `groupKey` of the last sync; a change forces a rebuild. */
|
||||
#groupKey: string | null = null;
|
||||
|
||||
#rebuild(items: T[], grouping: LaneGrouping<T, H, R>): void {
|
||||
this.#order = [];
|
||||
this.#headers = new Map();
|
||||
this.#rows = new Map();
|
||||
const ensure = (key: string, header: H): R[] => {
|
||||
let arr = this.#rows.get(key);
|
||||
if (arr === undefined) {
|
||||
arr = [];
|
||||
this.#rows.set(key, arr);
|
||||
this.#headers.set(key, header);
|
||||
this.#order.push(key);
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
const sink: LaneSink<H, R> = {
|
||||
open: (key, header) => void ensure(key, header),
|
||||
push: (key, header, row) => ensure(key, header).push(row)
|
||||
};
|
||||
for (const item of items) grouping.emit(item, sink);
|
||||
this.#items = items;
|
||||
}
|
||||
|
||||
#extend(items: T[], grouping: LaneGrouping<T, H, R>): void {
|
||||
const fresh = items.slice(this.#items.length);
|
||||
// Collect the fresh page's rows per touched lane, plus the keys the page
|
||||
// newly introduces (in first-appearance order). Untouched lanes are never
|
||||
// entered here, so they keep their exact existing `rows` reference.
|
||||
const freshByKey = new Map<string, R[]>();
|
||||
const newKeys: string[] = [];
|
||||
const touch = (key: string, header: H): R[] => {
|
||||
let add = freshByKey.get(key);
|
||||
if (add === undefined) {
|
||||
add = [];
|
||||
freshByKey.set(key, add);
|
||||
if (!this.#rows.has(key)) {
|
||||
newKeys.push(key);
|
||||
this.#headers.set(key, header);
|
||||
}
|
||||
}
|
||||
return add;
|
||||
};
|
||||
const sink: LaneSink<H, R> = {
|
||||
open: (key, header) => void touch(key, header),
|
||||
push: (key, header, row) => touch(key, header).push(row)
|
||||
};
|
||||
for (const item of fresh) grouping.emit(item, sink);
|
||||
for (const [k, add] of freshByKey) {
|
||||
const existing = this.#rows.get(k);
|
||||
// New lane → adopt the fresh array; grown lane → fresh concat (new
|
||||
// reference, so a keyed `{#each}` refreshes exactly the grown lanes).
|
||||
this.#rows.set(k, existing === undefined ? add : existing.concat(add));
|
||||
}
|
||||
for (const k of newKeys) this.#order.push(k);
|
||||
this.#items = items;
|
||||
}
|
||||
|
||||
sync(items: T[], grouping: LaneGrouping<T, H, R>): Lane<H, R>[] {
|
||||
if (this.#groupKey === grouping.groupKey && isAppendExtension(this.#items, items)) {
|
||||
this.#extend(items, grouping);
|
||||
} else {
|
||||
this.#rebuild(items, grouping);
|
||||
}
|
||||
this.#groupKey = grouping.groupKey;
|
||||
return this.#order.map((k) => ({
|
||||
key: k,
|
||||
header: this.#headers.get(k)!,
|
||||
rows: this.#rows.get(k)!
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { primeContextPage } from '$lib/utils/listContext';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
|
||||
import { errorToast } from '$lib/utils/errors';
|
||||
@@ -52,11 +53,10 @@
|
||||
// items on this page are favorites — pass every id in `favoriteIds`
|
||||
// so the star widget lights up universally.
|
||||
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
|
||||
const contextMap = $derived(
|
||||
new Map<string, ItemContext>(
|
||||
raw.map((it) => [it.resource.id, { date: it.favorited_at } satisfies ItemContext])
|
||||
)
|
||||
);
|
||||
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
|
||||
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
|
||||
// on every infinite-scroll page. Mirrors the sibling `favoriteIds` SvelteSet.
|
||||
const contextMap = new SvelteMap<string, ItemContext>();
|
||||
// Persistent reactive set, updated in place per page (add the fresh page's
|
||||
// ids; clear on reset) instead of rebuilding a brand-new SvelteSet over the
|
||||
// whole accumulated list on every infinite-scroll page — that was O(N²)
|
||||
@@ -117,6 +117,10 @@
|
||||
// reset, then add only this page's ids (benches/ROUND14.md §F2).
|
||||
if (reset) favoriteIds.clear();
|
||||
for (const it of page.items) favoriteIds.add(it.resource.id);
|
||||
primeContextPage(contextMap, reset, page.items, (it) => [
|
||||
it.resource.id,
|
||||
{ date: it.favorited_at }
|
||||
]);
|
||||
cursor = page.next_cursor;
|
||||
void owners.resolve(page.items.map((i) => i.resource.created_by));
|
||||
} catch (e) {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { primeContextPage } from '$lib/utils/listContext';
|
||||
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
|
||||
import {
|
||||
addFavorite,
|
||||
@@ -59,14 +60,10 @@
|
||||
// shared `isDotfile` predicate purely for the empty-state message
|
||||
// below (distinguishes "genuinely empty" from "everything filtered").
|
||||
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
|
||||
const contextMap = $derived(
|
||||
new Map<string, ItemContext>(
|
||||
raw.map((it) => [
|
||||
it.resource.id,
|
||||
{ date: it.accessed_at, ownerId: it.resource.updated_by ?? null } satisfies ItemContext
|
||||
])
|
||||
)
|
||||
);
|
||||
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
|
||||
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
|
||||
// on every infinite-scroll page. Mirrors the sibling `favoriteIds` SvelteSet.
|
||||
const contextMap = new SvelteMap<string, ItemContext>();
|
||||
const hiddenCount = $derived(
|
||||
preferences.hideDotfiles ? items.filter((i) => isDotfile(i.name)).length : 0
|
||||
);
|
||||
@@ -131,6 +128,10 @@
|
||||
resourceTypes: ['file', 'folder']
|
||||
});
|
||||
raw = reset ? page.items : [...raw, ...page.items];
|
||||
primeContextPage(contextMap, reset, page.items, (it) => [
|
||||
it.resource.id,
|
||||
{ date: it.accessed_at, ownerId: it.resource.updated_by ?? null }
|
||||
]);
|
||||
cursor = page.next_cursor;
|
||||
void owners.resolve(page.items.map((i) => i.resource.updated_by));
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { errorMessage } from '$lib/utils/errors';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { primeContextPage } from '$lib/utils/listContext';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -43,14 +45,11 @@
|
||||
// so the sharer shows up in the vignette (rather than the resource's
|
||||
// intrinsic `created_by`, which is a stranger for grantees).
|
||||
const items = $derived(fileFolderGrants.map((it) => it.resource as FileItem | FolderItem));
|
||||
const contextMap = $derived(
|
||||
new Map<string, ItemContext>(
|
||||
fileFolderGrants.map((it) => [
|
||||
it.resource.id,
|
||||
{ date: it.granted_at, ownerId: it.granted_by ?? null } satisfies ItemContext
|
||||
])
|
||||
)
|
||||
);
|
||||
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
|
||||
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
|
||||
// on every infinite-scroll page. Drives are skipped (they never reach the
|
||||
// row UI), so the map covers exactly the displayed `fileFolderGrants`.
|
||||
const contextMap = new SvelteMap<string, ItemContext>();
|
||||
|
||||
// Server-supported sort_by values (see grant_handler.rs:615):
|
||||
// granted_at, granted_by, name, type
|
||||
@@ -96,6 +95,11 @@
|
||||
reverse: rev
|
||||
});
|
||||
raw = reset ? page.items : [...raw, ...page.items];
|
||||
primeContextPage(contextMap, reset, page.items, (it) =>
|
||||
it.resource_type === 'drive'
|
||||
? null
|
||||
: [it.resource.id, { date: it.granted_at, ownerId: it.granted_by ?? null }]
|
||||
);
|
||||
cursor = page.next_cursor;
|
||||
// Warm the sharer-name cache so the "Shared by" group headers
|
||||
// show real names instead of UUIDs.
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
||||
import { SharedLanesBuilder, type LaneGrouping } from '$lib/utils/sharedLanes';
|
||||
|
||||
type GroupBy = 'items' | 'sharedWith';
|
||||
|
||||
@@ -178,47 +179,58 @@
|
||||
rows: { grant: OutgoingResourceGrant; item: OutgoingGrantItem }[];
|
||||
}
|
||||
|
||||
const lanes = $derived.by((): Lane[] => {
|
||||
const out: Lane[] = [];
|
||||
// Transient scratch map built inside $derived.by and discarded — not reactive state.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const byKey = new Map<string, Lane>();
|
||||
const ensure = (key: string, header: Lane['header']): Lane => {
|
||||
let lane = byKey.get(key);
|
||||
if (!lane) {
|
||||
lane = { key, header, rows: [] };
|
||||
byKey.set(key, lane);
|
||||
out.push(lane);
|
||||
}
|
||||
return lane;
|
||||
};
|
||||
for (const item of filteredRaw) {
|
||||
if (groupBy === 'items') {
|
||||
const lane = ensure(`resource:${item.resource.id}`, { kind: 'resource', item });
|
||||
for (const grant of item.grants) lane.rows.push({ grant, item });
|
||||
} else {
|
||||
for (const grant of item.grants) {
|
||||
let key: string;
|
||||
let header: Lane['header'];
|
||||
if (grant.subject_type === 'user') {
|
||||
key = `user:${grant.subject_id}`;
|
||||
header = { kind: 'user', id: grant.subject_id };
|
||||
} else if (grant.subject_type === 'group') {
|
||||
key = `group:${grant.subject_id}`;
|
||||
header = { kind: 'group', id: grant.subject_id };
|
||||
} else if (grant.has_password) {
|
||||
key = 'links:password';
|
||||
header = { kind: 'linkPassword' };
|
||||
} else {
|
||||
key = 'links:public';
|
||||
header = { kind: 'linkPublic' };
|
||||
type LaneRow = Lane['rows'][number];
|
||||
|
||||
// The active grouping as a stable-identity descriptor: `groupKey` changes
|
||||
// only when the user switches group-by, so an infinite-scroll page (or a
|
||||
// grant edit that reassigns `raw`) takes the builder's O(N) incremental path
|
||||
// instead of re-bucketing the whole accumulated list. `emit` reproduces the
|
||||
// old derive exactly — `open` is the old unconditional `ensure` (a by-files
|
||||
// lane exists even with zero grants); `push` is `ensure(...).rows.push`.
|
||||
const laneGrouping = $derived.by(
|
||||
(): LaneGrouping<OutgoingGrantItem, Lane['header'], LaneRow> =>
|
||||
groupBy === 'items'
|
||||
? {
|
||||
groupKey: 'items',
|
||||
emit: (item, sink) => {
|
||||
const key = `resource:${item.resource.id}`;
|
||||
const header: Lane['header'] = { kind: 'resource', item };
|
||||
sink.open(key, header);
|
||||
for (const grant of item.grants) sink.push(key, header, { grant, item });
|
||||
}
|
||||
}
|
||||
ensure(key, header).rows.push({ grant, item });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
: {
|
||||
groupKey: 'sharedWith',
|
||||
emit: (item, sink) => {
|
||||
for (const grant of item.grants) {
|
||||
let key: string;
|
||||
let header: Lane['header'];
|
||||
if (grant.subject_type === 'user') {
|
||||
key = `user:${grant.subject_id}`;
|
||||
header = { kind: 'user', id: grant.subject_id };
|
||||
} else if (grant.subject_type === 'group') {
|
||||
key = `group:${grant.subject_id}`;
|
||||
header = { kind: 'group', id: grant.subject_id };
|
||||
} else if (grant.has_password) {
|
||||
key = 'links:password';
|
||||
header = { kind: 'linkPassword' };
|
||||
} else {
|
||||
key = 'links:public';
|
||||
header = { kind: 'linkPublic' };
|
||||
}
|
||||
sink.push(key, header, { grant, item });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Persistent across reactive ticks: re-buckets only the freshly-appended page
|
||||
// and hands back the same rows-array reference for untouched lanes, falling
|
||||
// back to a full rebuild (deep-equal to the pure `buildLanes` reference) on a
|
||||
// group-by switch, grant edit or kind-filter toggle. Mirrors ResourceList's
|
||||
// `sectionsBuilder` (benches/ROUND16.md §F1).
|
||||
const lanesBuilder = new SharedLanesBuilder<OutgoingGrantItem, Lane['header'], LaneRow>();
|
||||
const lanes = $derived.by(() => lanesBuilder.sync(filteredRaw, laneGrouping));
|
||||
|
||||
function laneTitle(header: Lane['header']): string {
|
||||
switch (header.kind) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { errorToast } from '$lib/utils/errors';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { primeContextPage } from '$lib/utils/listContext';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
deleteTrashItem,
|
||||
@@ -44,20 +46,10 @@
|
||||
// travel through `contextMap`, which page-provided group-by / render
|
||||
// callbacks read via the `ctx` parameter.
|
||||
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
|
||||
const contextMap = $derived(
|
||||
new Map<string, ItemContext>(
|
||||
raw.map((it) => [
|
||||
it.resource.id,
|
||||
{
|
||||
date: it.deletion_date,
|
||||
extras: {
|
||||
driveId: it.drive_id,
|
||||
trashedAt: it.trashed_at
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
);
|
||||
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
|
||||
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
|
||||
// on every infinite-scroll page. Mirrors the shipped `favoriteIds` SvelteSet.
|
||||
const contextMap = new SvelteMap<string, ItemContext>();
|
||||
|
||||
// "Drive" group rank: default-personal first, then secondary personal, then
|
||||
// shared — matches `DrivePicker.svelte::sortedDrives` so the sidebar and
|
||||
@@ -140,6 +132,10 @@
|
||||
resourceTypes: ['file', 'folder']
|
||||
});
|
||||
raw = reset ? page.items : [...raw, ...page.items];
|
||||
primeContextPage(contextMap, reset, page.items, (it) => [
|
||||
it.resource.id,
|
||||
{ date: it.deletion_date, extras: { driveId: it.drive_id, trashedAt: it.trashed_at } }
|
||||
]);
|
||||
cursor = page.next_cursor;
|
||||
} catch (e) {
|
||||
console.error('trash: load error', e);
|
||||
|
||||
Reference in New Issue
Block a user