Skip to content
Open
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
14 changes: 10 additions & 4 deletions src/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions src/settings/sections/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 `<os-select>` 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();
Expand Down
73 changes: 73 additions & 0 deletions src/window-links/ensure-visuals.ts
Original file line number Diff line number Diff line change
@@ -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 `<os-select>` — 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;
}
85 changes: 85 additions & 0 deletions tests/vitest/window-link-ensure-visuals.test.ts
Original file line number Diff line number Diff line change
@@ -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 );
} );
} );