diff --git a/src/desktop.ts b/src/desktop.ts index d7f3e8da2..9f45385b3 100644 --- a/src/desktop.ts +++ b/src/desktop.ts @@ -100,10 +100,10 @@ import type { } from './window-links/types'; import { createUnfocusEffectRegistrySync } from './effects/server-sync'; import { createWindowLinkRendererRegistrySync } from './window-links/server-sync'; +import { ensureWindowLinkVisuals } from './window-links/ensure-visuals'; import { startUnfocusEngine } from './effects/unfocus-engine'; import { startWindowRevealEngine } from './reveals/engine'; import { createDockRailRendererSync } from './dock-rail/server-sync'; -import { loadVendorScript } from './wallpapers/vendor-loader'; import { installDockConstellationSentinel } from './dock-constellation/sentinel'; import { type WindowThemeDef, @@ -3542,12 +3542,18 @@ function init(): void { HOOKS.WINDOW_LINK_GROUPS_CHANGED, 'desktop-mode/window-link-visuals-sentinel', () => { - if ( visualsRequested || ! config.windowLinkVisualsBundleUrl ) { + if ( visualsRequested ) { return; } visualsRequested = true; - void loadVendorScript( config.windowLinkVisualsBundleUrl ) - .then( () => { + // Shared with the Preferences "Link style" picker, which + // needs the same bundle for its registrations alone — + // whichever asks second shares the first one's fetch. + void ensureWindowLinkVisuals() + .then( ( loaded ) => { + if ( ! loaded ) { + return; + } window.openStationWindowLinkVisuals?.start( { manager, osSettings, diff --git a/src/settings/sections/effects.ts b/src/settings/sections/effects.ts index f8b533060..699eead54 100644 --- a/src/settings/sections/effects.ts +++ b/src/settings/sections/effects.ts @@ -28,6 +28,7 @@ import { subscribeWindowLinkRenderers, WINDOW_LINK_RENDERER_NONE as LINKS_NONE, } from '../../window-links/renderer-registry'; +import { ensureWindowLinkVisuals } from '../../window-links/ensure-visuals'; import { listWindowReveals, REVEAL_DURATION_AUTO, @@ -291,6 +292,18 @@ export function buildEffectsSection( ctx: SettingsCtx ): HTMLElement { paint(); } ); + // The built-in `svg-splines` renderer registers itself as a + // load-time side effect of the visuals bundle, which the shell only + // fetches once two windows actually relate. Until then this list + // holds nothing but `None`, while the stored value is still + // `svg-splines` — and a `` asked to show a value no + // option carries renders blank. Pull the bundle in when the tab is + // on screen so the dropdown can describe the setting that is + // actually in force; the subscription above repaints when the + // registrations land. Failure is survivable — the list simply stays + // as it was — so the rejection is swallowed rather than surfaced. + void ensureWindowLinkVisuals().catch( () => {} ); + const observer = new MutationObserver( () => { if ( ! wrapper.isConnected ) { unsubscribe(); diff --git a/src/window-links/ensure-visuals.ts b/src/window-links/ensure-visuals.ts new file mode 100644 index 000000000..a1f519caf --- /dev/null +++ b/src/window-links/ensure-visuals.ts @@ -0,0 +1,73 @@ +/** + * OpenStation — on-demand loader for the window-link visuals bundle. + * + * `window-link-visuals[.min].js` carries the render host, its geometry + * and the built-in `svg-splines` renderer. It is deliberately lazy: + * `src/desktop.ts` pulls it in on the first relation group the engine + * reports, so a session whose windows never relate never pays for it. + * + * That laziness had one victim. The bundle is also what REGISTERS + * `svg-splines` into the renderer registry (registration is a load-time + * side effect — see `visuals-entry.ts`), and OpenStation Preferences + * builds its "Link style" dropdown from that same registry. Open + * Preferences in a session where no two windows had yet related, and + * the registry held nothing: the only option was `None`, the stored + * value was still `svg-splines`, and `` — asked to display a + * value no option carries — rendered blank. The setting looked broken + * while working perfectly. + * + * So both callers route through here: the sentinel in `desktop.ts` + * that needs the host in order to draw, and the settings section that + * needs only the registrations in order to list them. `loadVendorScript` + * de-duplicates by URL, so whichever arrives second shares the first + * one's fetch, and the registry's `createSharedStore` backing means the + * registration is visible to every bundle regardless of who triggered + * it. + */ + +import type { DesktopConfig } from '../types'; +import { loadVendorScript } from '../wallpapers/vendor-loader'; + +let inflight: Promise< boolean > | null = null; + +/** + * Load the visuals bundle, once. + * + * Resolves `true` when the bundle is in the page (or already was), + * `false` when there is no bundle URL to load — a site old enough not + * to ship one, where the caller should simply carry on without it. + * + * A failed load clears the memo so a later caller can retry rather + * than being stuck with a registry that will never fill. + */ +export function ensureWindowLinkVisuals(): Promise< boolean > { + if ( inflight ) { + return inflight; + } + + if ( window.openStationWindowLinkVisuals ) { + inflight = Promise.resolve( true ); + return inflight; + } + + const config = ( + window as unknown as { openStationConfig?: DesktopConfig } + ).openStationConfig; + const url = config?.windowLinkVisualsBundleUrl; + if ( ! url ) { + return Promise.resolve( false ); + } + + inflight = loadVendorScript( url ) + .then( () => true ) + .catch( ( err ) => { + inflight = null; + throw err; + } ); + return inflight; +} + +/** Test-only: forget the in-flight memo. */ +export function __resetWindowLinkVisualsForTests(): void { + inflight = null; +} diff --git a/tests/vitest/window-link-ensure-visuals.test.ts b/tests/vitest/window-link-ensure-visuals.test.ts new file mode 100644 index 000000000..7088db45f --- /dev/null +++ b/tests/vitest/window-link-ensure-visuals.test.ts @@ -0,0 +1,85 @@ +/** + * The visuals bundle is lazy, and it is also what registers the + * built-in `svg-splines` renderer. Preferences builds its "Link style" + * dropdown from that registry, so opening Preferences before any two + * windows related left the select with only `None` while the stored + * value was `svg-splines` — and it rendered blank. + * + * These pin the shared loader both callers now route through. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vendorLoader from '../../src/wallpapers/vendor-loader'; +import { + ensureWindowLinkVisuals, + __resetWindowLinkVisualsForTests, +} from '../../src/window-links/ensure-visuals'; + +type Carrier = { + openStationConfig?: { windowLinkVisualsBundleUrl?: string }; + openStationWindowLinkVisuals?: unknown; +}; + +const w = window as unknown as Carrier; + +describe( 'ensureWindowLinkVisuals', () => { + beforeEach( () => { + __resetWindowLinkVisualsForTests(); + delete w.openStationWindowLinkVisuals; + w.openStationConfig = { + windowLinkVisualsBundleUrl: 'https://example.test/visuals.js', + }; + vi.spyOn( vendorLoader, 'loadVendorScript' ).mockResolvedValue( + undefined, + ); + } ); + + afterEach( () => { + vi.restoreAllMocks(); + } ); + + it( 'loads the bundle from the config URL', async () => { + await expect( ensureWindowLinkVisuals() ).resolves.toBe( true ); + + expect( vendorLoader.loadVendorScript ).toHaveBeenCalledWith( + 'https://example.test/visuals.js', + ); + } ); + + it( 'loads once however many callers ask', async () => { + // The Preferences picker and the shell's groups-changed + // sentinel can both ask, in either order. + await Promise.all( [ + ensureWindowLinkVisuals(), + ensureWindowLinkVisuals(), + ] ); + await ensureWindowLinkVisuals(); + + expect( vendorLoader.loadVendorScript ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'does not re-fetch a bundle the page already has', async () => { + w.openStationWindowLinkVisuals = { start: () => {} }; + + await expect( ensureWindowLinkVisuals() ).resolves.toBe( true ); + + expect( vendorLoader.loadVendorScript ).not.toHaveBeenCalled(); + } ); + + it( 'resolves false, without throwing, when no bundle URL is configured', async () => { + w.openStationConfig = {}; + + await expect( ensureWindowLinkVisuals() ).resolves.toBe( false ); + expect( vendorLoader.loadVendorScript ).not.toHaveBeenCalled(); + } ); + + it( 'clears the memo after a failure so a later caller can retry', async () => { + vi.mocked( vendorLoader.loadVendorScript ).mockRejectedValueOnce( + new Error( 'offline' ), + ); + + await expect( ensureWindowLinkVisuals() ).rejects.toThrow( 'offline' ); + await expect( ensureWindowLinkVisuals() ).resolves.toBe( true ); + expect( vendorLoader.loadVendorScript ).toHaveBeenCalledTimes( 2 ); + } ); +} );