After signing out and back in, a texture fill stayed in its base colour until something else redrew the vector tab. The texture library is a singleton that outlives the editor. Four widgets subscribed to it and never unsubscribed. After a new sign-in the first loaded texture called a destroyed canvas, which threw and stopped the loop before the vector tab heard about the image. - TextureCanvas, TileCanvas, ShaderPreview and WorldViewport now unsubscribe in destroy(). This is the pattern FontPreview and VectorArtCanvas already use. WorldViewport leaves both the texture and the font library. - FileLibrary.notifyChange catches errors per listener. A broken listener no longer starves the others. The error is still logged as "[textures] a listener failed". - e2e 47i no longer forces a redraw. It waits up to 3 s for the tab to show the texture on its own. Findings: - The trigger was e2e module 45 (sign out, sign in), not the texture fill. My note in #196 that the browser showed no exception was wrong. The counter-check shows it clearly. Tests: - ./check.sh: 137 files, 2051 tests, exit 0 (before 136 / 2049) - 2 new unit tests in tests/viewport/file-library.test.ts - GM_STEPS=45,47 after a fresh seed: ALL PASSED, 71 checks - Counter-check with the old source and the new check: 1 FAIL (shape stays #0000ff) plus the TypeError. Old FileLibrary.ts fails the first unit test. - No full e2e run locally. The check count is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
68 lines
2.2 KiB
TypeScript
68 lines
2.2 KiB
TypeScript
/**
|
|
* The base of the picture and font libraries: a file asked for is fetched once, and the listeners
|
|
* hear when it lands. The libraries are module singletons and outlive an editor that was signed out
|
|
* of, so a listener can belong to a widget that is gone (#196).
|
|
*/
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { FileLibrary } from '../../src/viewport/FileLibrary';
|
|
|
|
/** A library whose loads a test resolves by hand. */
|
|
class HandLibrary extends FileLibrary<string> {
|
|
readonly kept = new Map<string, string>();
|
|
private readonly pending: (() => void)[] = [];
|
|
|
|
constructor() {
|
|
super('/files', 'test');
|
|
}
|
|
|
|
ask(file: string): void {
|
|
if (!this.kept.has(file)) this.startLoading(file);
|
|
}
|
|
|
|
land(): Promise<void> {
|
|
for (const resolve of this.pending.splice(0)) resolve();
|
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
protected fetchFile(url: string): Promise<string> {
|
|
return new Promise((resolve) => this.pending.push(() => resolve(url)));
|
|
}
|
|
|
|
protected keep(file: string, loaded: string): void {
|
|
this.kept.set(file, loaded);
|
|
}
|
|
}
|
|
|
|
describe('the file library', () => {
|
|
it('tells every listener when a file lands, even after one of them threw', async () => {
|
|
const library = new HandLibrary();
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
// First in line, as the canvas of an editor signed out of is: its graphics are destroyed.
|
|
library.onChange(() => {
|
|
throw new TypeError("Cannot read properties of null (reading 'clear')");
|
|
});
|
|
const later = vi.fn();
|
|
library.onChange(later);
|
|
|
|
library.ask('a.png');
|
|
await library.land();
|
|
|
|
expect(library.kept.get('a.png')).toBe('/files/a.png');
|
|
expect(later).toHaveBeenCalledTimes(1);
|
|
// Not swallowed: the broken listener still shows in the console.
|
|
expect(error).toHaveBeenCalledWith('[test] a listener failed', expect.any(TypeError));
|
|
error.mockRestore();
|
|
});
|
|
|
|
it('forgets a listener once its unsubscribe has run', async () => {
|
|
const library = new HandLibrary();
|
|
const gone = vi.fn();
|
|
const stop = library.onChange(gone);
|
|
stop();
|
|
|
|
library.ask('a.png');
|
|
await library.land();
|
|
expect(gone).not.toHaveBeenCalled();
|
|
});
|
|
});
|