Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
77 changes: 75 additions & 2 deletions src/store/labelStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useLabelStore, currentObjects } from './labelStore';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type * as Labelary from '../lib/labelary';
import {
useLabelStore,
currentObjects,
__resetPreviewCacheForTests,
} from './labelStore';
import { isGroup, type LabelObject } from '../types/Group';
import { defined, props } from '../test/helpers';

// Mock the Labelary client so cache tests run without network I/O.
// `fetchPreview` returns a unique blob URL on each call so the test can
// see when a re-fetch happened vs. a cache hit (same URL = cached).
vi.mock('../lib/labelary', async (importOriginal) => {
const actual = await importOriginal<typeof Labelary>();
let counter = 0;
return {
...actual,
fetchPreview: vi.fn(async () => `blob:mock-${++counter}`),
};
});

// URL.revokeObjectURL is a no-op stub in jsdom by default but emits a
// warning in some setups; provide an explicit spy so we can also assert
// the cache revokes the previous URL on miss without flagging warnings.
// Also drop any cached preview entry so the cache tests stay isolated.
beforeEach(() => {
globalThis.URL.revokeObjectURL = vi.fn();
__resetPreviewCacheForTests();
});

/** Reset store to clean state before each test. */
function reset() {
useLabelStore.setState({
Expand Down Expand Up @@ -943,4 +969,51 @@ describe('ungroup', () => {
expect(state().previewMode.status).toBe('idle');
});
});

describe('preview cache', () => {
function activeUrl(): string | null {
const mode = state().previewMode;
return mode.status === 'active' ? mode.url : null;
}

it('skips the fetch when the ZPL is unchanged across toggles', async () => {
const labelary = await import('../lib/labelary');
const fetchSpy = vi.mocked(labelary.fetchPreview);
fetchSpy.mockClear();

await state().enterPreviewMode();
expect(state().previewMode.status).toBe('active');
expect(fetchSpy).toHaveBeenCalledTimes(1);
const firstUrl = activeUrl();

state().exitPreviewMode();
expect(state().previewMode.status).toBe('idle');

// Re-open with no changes: cache hit, no extra fetch, same URL.
await state().enterPreviewMode();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(activeUrl()).toBe(firstUrl);
});

it('re-fetches and revokes the stale URL when the ZPL changes', async () => {
const labelary = await import('../lib/labelary');
const fetchSpy = vi.mocked(labelary.fetchPreview);
fetchSpy.mockClear();
const revokeSpy = vi.mocked(globalThis.URL.revokeObjectURL);

await state().enterPreviewMode();
const firstUrl = activeUrl();
expect(firstUrl).toBeTruthy();
state().exitPreviewMode();

// Mutate the design — the next enterPreviewMode generates different
// ZPL and must hit the API again, revoking the prior blob.
state().addObject('text');
await state().enterPreviewMode();

expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(revokeSpy).toHaveBeenCalledWith(firstUrl);
expect(activeUrl()).not.toBe(firstUrl);
});
});
});
51 changes: 47 additions & 4 deletions src/store/labelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,42 @@ function updateCurrentObjects(
* multiplies it by pasteCount because the clipboard source stays put. */
const DUPLICATE_OFFSET_DOTS = 20;

/** Single-entry cache for the Labelary preview blob URL, keyed by the
* exact ZPL string that produced it. Module-level rather than store-
* state because the blob URL is a non-serialisable side-effect handle:
* persisting it through `partialize` would resurrect a stale identifier
* across reloads, and including it in Zustand state would churn every
* selector that observes the store.
*
* The closure owns the URL: `set` revokes the previous blob before
* replacing it, so callers can't leak by forgetting to clean up. */
const previewCache = (() => {
let entry: { zpl: string; url: string } | null = null;
return {
/** Returns the cached URL if `zpl` matches the cached key, else null. */
get(zpl: string): string | null {
return entry && entry.zpl === zpl ? entry.url : null;
},
/** Stores a fresh (zpl, url) pair. Revokes the previously held URL
* if any so the browser can reclaim the blob memory. */
set(zpl: string, url: string): void {
if (entry) URL.revokeObjectURL(entry.url);
entry = { zpl, url };
},
/** Test-only: drop the cached entry without revoking, so a fresh
* test starts from a clean slate. Production callers should never
* need this — `set` handles eviction on its own. */
_resetForTests(): void {
entry = null;
},
};
})();

/** Test-only handle to clear the preview cache between test cases.
* Marked underscored to discourage production use; the cache otherwise
* manages its own lifecycle via the `set` revoke path. */
export const __resetPreviewCacheForTests = (): void => previewCache._resetForTests();

/** Build offset copies of objects identified by `ids`. Missing ids are
* silently dropped. Props are shallow-cloned to match the pattern in
* copySelectedObjects — even though no current code path mutates props,
Expand Down Expand Up @@ -840,9 +876,16 @@ export const useLabelStore = create<LabelState>()(
if (state.previewMode.status === 'loading' || state.previewMode.status === 'active') {
return;
}
set({ previewMode: { status: 'loading' } });
const objs = currentObjects(state);
const zpl = generateZPL(state.label, objs);
// Toggling preview off then on for a side-by-side pixel compare
// shouldn't burn an API call when nothing changed.
const cachedUrl = previewCache.get(zpl);
if (cachedUrl !== null) {
set({ previewMode: { status: 'active', url: cachedUrl } });
return;
}
set({ previewMode: { status: 'loading' } });
try {
const url = await fetchPreview(zpl, state.label);
// Avoid clobbering an exit that happened while the request was in
Expand All @@ -851,6 +894,7 @@ export const useLabelStore = create<LabelState>()(
URL.revokeObjectURL(url);
return;
}
previewCache.set(zpl, url);
Comment thread
u8array marked this conversation as resolved.
set({ previewMode: { status: 'active', url } });
} catch (e) {
if (get().previewMode.status !== 'loading') return;
Expand All @@ -860,9 +904,8 @@ export const useLabelStore = create<LabelState>()(

exitPreviewMode: () =>
set((state) => {
if (state.previewMode.status === 'active') {
URL.revokeObjectURL(state.previewMode.url);
}
// The blob URL is owned by `previewCache` and intentionally
// kept alive across exits so a re-toggle skips the fetch.
if (state.previewMode.status === 'idle') return {};
return { previewMode: { status: 'idle' } };
}),
Expand Down