diff --git a/packages/eslint-plugin/src/rules/no-deprecated-properties.spec.ts b/packages/eslint-plugin/src/rules/no-deprecated-properties.spec.ts
index acccfd058..45f2005fa 100644
--- a/packages/eslint-plugin/src/rules/no-deprecated-properties.spec.ts
+++ b/packages/eslint-plugin/src/rules/no-deprecated-properties.spec.ts
@@ -25,6 +25,18 @@ describe('no-deprecated-properties', () => {
}`,
options: [{ version: 'v3' }],
},
+ // v4 has $warn and $error again: they report on the cancelable
+ // diagnostic channel rather than writing to the console.
+ {
+ code: `class Slider extends Base {
+ static config = { name: 'Slider' };
+ mounted() {
+ this.$warn('slider.off', 'Off.');
+ this.$error('slider.failed', 'Failed.', new Error('why'));
+ }
+ }`,
+ options: v4,
+ },
// v4 keeps $services — only its two switches are gone.
{
code: `class Slider extends Base {
@@ -79,14 +91,6 @@ describe('no-deprecated-properties', () => {
options: v4,
errors: [{ messageId: 'removed' }],
},
- {
- code: `class Slider extends Base {
- static config = { name: 'Slider' };
- mounted() { this.$warn('a'); }
- }`,
- options: v4,
- errors: [{ messageId: 'removed' }],
- },
{
code: `class Slider extends Base {
static config = { name: 'Slider' };
diff --git a/packages/eslint-plugin/src/rules/no-deprecated-properties.ts b/packages/eslint-plugin/src/rules/no-deprecated-properties.ts
index 8976ba530..4a7893866 100644
--- a/packages/eslint-plugin/src/rules/no-deprecated-properties.ts
+++ b/packages/eslint-plugin/src/rules/no-deprecated-properties.ts
@@ -24,7 +24,10 @@ const V4_REMOVED = new Map([
['$root', '$closest()'],
['$children', '$watchChildren()'],
['$update', 'nothing — $refs and $options read the DOM on every access'],
- ['$warn', 'console.warn()'],
+ // `$warn` is not listed: v4 has one again, and it is a different thing from
+ // v3's logger — it reports on the cancelable diagnostic channel, filling in
+ // the component name and element, rather than writing to the console. See
+ // `$error` for the counterpart that carries a cause.
['$log', 'console.log()'],
['$terminate', '$destroy()'],
]);
diff --git a/packages/v4/migration/Action/Action.spec.ts b/packages/v4/migration/Action/Action.spec.ts
index 48996c4fc..40ff6ef53 100644
--- a/packages/v4/migration/Action/Action.spec.ts
+++ b/packages/v4/migration/Action/Action.spec.ts
@@ -461,16 +461,31 @@ describe('Action — the component', () => {
expect(el.id).toBe('foo');
});
- it('warns instead of throwing when the effect fails', async () => {
+ it('reports on the diagnostic channel instead of throwing when the effect fails', async () => {
const root = await render(`
`);
- const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const details: Array> = [];
+ // Canceling suppresses the default sink, so the failure does not reach
+ // `reportError()` and fail the run.
+ const listener = (event: Event) => {
+ details.push((event as CustomEvent>).detail);
+ event.preventDefault();
+ };
+ document.addEventListener('js-toolkit:diagnostic', listener);
click(root.querySelector('#action') as Element);
- expect(spy).toHaveBeenCalledTimes(1);
- spy.mockRestore();
+ expect(details).toHaveLength(1);
+ expect(details[0]).toMatchObject({
+ severity: 'error',
+ code: 'action.effect-failed',
+ component: 'Action',
+ });
+ // The cause survives, which a bare `console.warn` never carried.
+ expect(details[0].error).toBeInstanceOf(Error);
+
+ document.removeEventListener('js-toolkit:diagnostic', listener);
});
});
diff --git a/packages/v4/migration/Action/ActionEvent.ts b/packages/v4/migration/Action/ActionEvent.ts
index 1fcfcab39..8b5f09c3f 100644
--- a/packages/v4/migration/Action/ActionEvent.ts
+++ b/packages/v4/migration/Action/ActionEvent.ts
@@ -14,10 +14,6 @@ const DEFAULT_DEBOUNCE_DELAY = 100;
/** A resolved target: one entry, keyed by the component's name. */
export type ActionTarget = Record;
-function warn(...args: unknown[]): void {
- console.warn('[action]', ...args);
-}
-
/** One runtime event binding from an attribute or the option triple. */
export class ActionEvent {
static targetSeparator = ' ';
@@ -170,7 +166,9 @@ export class ActionEvent {
(value as EffectFunction).apply(action.$el, args);
}
} catch (error) {
- warn(error);
+ // Reported as the `Action` this binding belongs to, which is the
+ // component a listener would want to filter on.
+ this.action.$error('action.effect-failed', 'An action effect threw.', error);
}
}
}
diff --git a/packages/v4/migration/AnchorNav/AnchorNav.spec.ts b/packages/v4/migration/AnchorNav/AnchorNav.spec.ts
new file mode 100644
index 000000000..ed84c8cbb
--- /dev/null
+++ b/packages/v4/migration/AnchorNav/AnchorNav.spec.ts
@@ -0,0 +1,100 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { registerComponents } from '../../src/index.js';
+import { getInstance, resetDom, settle } from '../../src/test-utils.js';
+import { AnchorNav } from './AnchorNav.js';
+import { AnchorNavLink } from './AnchorNavLink.js';
+import { AnchorNavTarget } from './AnchorNavTarget.js';
+
+const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px';
+const ONSCREEN = 'position:absolute;top:0;left:0;width:50px;height:50px';
+
+registerComponents(AnchorNav, AnchorNavLink, AnchorNavTarget);
+
+afterEach(resetDom);
+
+async function observed(): Promise {
+ for (let i = 0; i < 6; i += 1) {
+ await settle();
+ }
+}
+
+/**
+ * `AnchorNav` fire-and-forgets the link's transition, and a kept end state
+ * lands a few frames after that — so the class is polled rather than assumed
+ * present once the observer has delivered. `MenuList`'s spec has the same
+ * helper for the same reason, after this shape flaked under full-suite load.
+ */
+async function waitForClass(el: HTMLElement, className: string, timeout = 1000): Promise {
+ const deadline = Date.now() + timeout;
+ while (!el.classList.contains(className)) {
+ if (Date.now() > deadline) {
+ throw new Error(`waitForClass: "${className}" never landed on the element`);
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+}
+
+async function render(): Promise<{ root: HTMLElement; target: HTMLElement }> {
+ const root = document.createElement('div');
+ root.innerHTML = `
+
+
+
+
`;
+ document.body.append(root);
+ await settle();
+ return { root, target: root.querySelector('#one') as HTMLElement };
+}
+
+describe('AnchorNav', () => {
+ it('enters the matching link once its target scrolls into view', async () => {
+ const { root, target } = await render();
+ const link = getInstance(
+ root.querySelector('[data-component="AnchorNavLink"]'),
+ 'AnchorNavLink',
+ );
+
+ target.setAttribute('style', ONSCREEN);
+ await observed();
+
+ expect(link.state).toBe('entering');
+ await waitForClass(link.$el, 'active');
+ });
+
+ it('leaves the matching link once its target scrolls back out of view', async () => {
+ const { root, target } = await render();
+ const link = getInstance(
+ root.querySelector('[data-component="AnchorNavLink"]'),
+ 'AnchorNavLink',
+ );
+
+ target.setAttribute('style', ONSCREEN);
+ await observed();
+ target.setAttribute('style', OFFSCREEN);
+ await observed();
+
+ expect(link.state).toBe('leaving');
+ expect(link.$el.classList.contains('active')).toBe(false);
+ });
+
+ it('ignores a link whose targetId does not match any target', async () => {
+ const root = document.createElement('div');
+ root.innerHTML = `
+
+
+
+
`;
+ document.body.append(root);
+ await settle();
+ const link = getInstance(
+ root.querySelector('[data-component="AnchorNavLink"]'),
+ 'AnchorNavLink',
+ );
+ const target = root.querySelector('#one') as HTMLElement;
+
+ target.setAttribute('style', ONSCREEN);
+ await observed();
+
+ expect(link.state).toBeNull();
+ });
+});
diff --git a/packages/v4/migration/AnchorNav/AnchorNav.ts b/packages/v4/migration/AnchorNav/AnchorNav.ts
new file mode 100644
index 000000000..03c11c274
--- /dev/null
+++ b/packages/v4/migration/AnchorNav/AnchorNav.ts
@@ -0,0 +1,41 @@
+import { Base, component, type BaseProps, type ChildrenCollection } from '../../src/index.js';
+import { AnchorNavLink } from './AnchorNavLink.js';
+import { AnchorNavTarget } from './AnchorNavTarget.js';
+
+export type AnchorNavProps = BaseProps;
+
+/**
+ * Coordinates `AnchorNavLink` children with their matching `AnchorNavTarget`
+ * sections. v3 reacted to the target's `mounted`/`destroyed` lifecycle events
+ * bubbling with their plain names; v4 dispatches those under a namespaced
+ * event type instead (`js-toolkit:component:mounted`), so magic-name
+ * delegation (`onAnchorNavTargetMounted`) cannot bind to them directly.
+ * `$watchChildren`'s `added`/`removed` callbacks answer the same question —
+ * they already fire exactly on a matching child's mount/unmount transitions.
+ *
+ * @link https://ui.studiometa.dev/reference/items/AnchorNav/
+ */
+@component({
+ name: 'AnchorNav',
+ components: { AnchorNavLink, AnchorNavTarget },
+})
+export class AnchorNav extends Base {
+ links: ChildrenCollection = this.$watchChildren('AnchorNavLink');
+
+ targets: ChildrenCollection = this.$watchChildren(
+ 'AnchorNavTarget',
+ {
+ added: (target) => this.#toggleLinksFor(target, 'enter'),
+ removed: (target) => this.#toggleLinksFor(target, 'leave'),
+ },
+ );
+
+ #toggleLinksFor(target: AnchorNavTarget, action: 'enter' | 'leave'): void {
+ const { id } = target.$el;
+ for (const link of this.links) {
+ if (link.targetId === id) {
+ void link[action]();
+ }
+ }
+ }
+}
diff --git a/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts b/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts
new file mode 100644
index 000000000..81d7770d5
--- /dev/null
+++ b/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts
@@ -0,0 +1,84 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { registerComponents } from '../../src/index.js';
+import { getInstance, resetDom, settle } from '../../src/test-utils.js';
+import { AnchorNavLink } from './AnchorNavLink.js';
+
+registerComponents(AnchorNavLink);
+
+afterEach(resetDom);
+
+const OPTIONS_ATTRS = [
+ 'data-option-enter-from="enter-from"',
+ 'data-option-enter-active="enter-active"',
+ 'data-option-enter-to="enter-to"',
+ 'data-option-enter-keep="true"',
+ 'data-option-leave-from="leave-from"',
+ 'data-option-leave-active="leave-active"',
+ 'data-option-leave-to="leave-to"',
+ 'data-option-leave-keep="true"',
+].join(' ');
+
+async function render(): Promise {
+ const root = document.createElement('div');
+ root.innerHTML = ``;
+ document.body.append(root);
+ await settle();
+ return getInstance(root.firstElementChild, 'AnchorNavLink');
+}
+
+describe('AnchorNavLink', () => {
+ it('reads the target id from the hash, without the `#`', async () => {
+ const instance = await render();
+ expect(instance.targetId).toBe('section-one');
+ });
+
+ it('runs the enter transition and emits its lifecycle events', async () => {
+ const instance = await render();
+ const events: string[] = [];
+ instance.$el.addEventListener('transition-enter', () => events.push('transition-enter'));
+ instance.$el.addEventListener('transition-enter-start', () =>
+ events.push('transition-enter-start'),
+ );
+ instance.$el.addEventListener('transition-enter-end', () =>
+ events.push('transition-enter-end'),
+ );
+
+ await instance.enter();
+
+ expect(instance.state).toBe('entering');
+ expect(instance.$el.className).toBe('enter-to');
+ expect(events).toEqual(['transition-enter', 'transition-enter-start', 'transition-enter-end']);
+ });
+
+ it('runs the leave transition, clearing the enter end state first', async () => {
+ const instance = await render();
+ await instance.enter();
+
+ await instance.leave();
+
+ expect(instance.state).toBe('leaving');
+ expect(instance.$el.className).toBe('leave-to');
+ });
+
+ it('toggles between enter and leave depending on its last state', async () => {
+ const instance = await render();
+
+ await instance.toggle();
+ expect(instance.state).toBe('entering');
+ expect(instance.$el.className).toBe('enter-to');
+
+ await instance.toggle();
+ expect(instance.state).toBe('leaving');
+ expect(instance.$el.className).toBe('leave-to');
+ });
+
+ it('still runs onClick for the inherited ScrollTo behaviour', async () => {
+ const instance = await render();
+ const event = new MouseEvent('click', { cancelable: true });
+
+ instance.onClick(event);
+
+ // No `#section-one` element in the document: the click is left alone.
+ expect(event.defaultPrevented).toBe(false);
+ });
+});
diff --git a/packages/v4/migration/AnchorNav/AnchorNavLink.ts b/packages/v4/migration/AnchorNav/AnchorNavLink.ts
new file mode 100644
index 000000000..1ea979737
--- /dev/null
+++ b/packages/v4/migration/AnchorNav/AnchorNavLink.ts
@@ -0,0 +1,29 @@
+import { component, type BaseProps } from '../../src/index.js';
+import { ScrollTo } from '../ScrollTo/index.js';
+import { withTransition, type TransitionProps } from '../Transition/index.js';
+
+export type AnchorNavLinkProps = BaseProps & TransitionProps;
+
+/**
+ * A `ScrollTo` link that also enters/leaves a CSS transition on itself,
+ * driven by `AnchorNav` as its matching `AnchorNavTarget` mounts and
+ * unmounts.
+ *
+ * `withTransition(ScrollTo)` is v3's own declaration restored — it mixed the
+ * same decorator onto `AnchorScrollTo` — and it is the case that shows why
+ * the mixin has to exist rather than the utilities being called directly:
+ * the transition belongs on a class that already extends something else.
+ *
+ * @link https://ui.studiometa.dev/reference/items/AnchorNav/
+ */
+@component({
+ name: 'AnchorNavLink',
+})
+export class AnchorNavLink extends withTransition(ScrollTo)<
+ AnchorNavLinkProps & T
+> {
+ /** The target section id, read from the link's hash. */
+ get targetId(): string {
+ return this.$el.hash.replace(/^#/, '');
+ }
+}
diff --git a/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts b/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts
new file mode 100644
index 000000000..e7dc5a0c6
--- /dev/null
+++ b/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts
@@ -0,0 +1,48 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { registerComponents } from '../../src/index.js';
+import { INSTANCES } from '../../src/protocol-symbols.js';
+import { resetDom, settle } from '../../src/test-utils.js';
+import { AnchorNavTarget } from './AnchorNavTarget.js';
+
+const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px';
+const ONSCREEN = 'position:absolute;top:0;left:0;width:50px;height:50px';
+
+registerComponents(AnchorNavTarget);
+
+afterEach(resetDom);
+
+async function observed(): Promise {
+ for (let i = 0; i < 6; i += 1) {
+ await settle();
+ }
+}
+
+function render(style: string): HTMLElement {
+ const el = document.createElement('div');
+ el.setAttribute('data-component', 'AnchorNavTarget');
+ el.setAttribute('style', style);
+ document.body.append(el);
+ return el;
+}
+
+describe('AnchorNavTarget', () => {
+ it('mounts once scrolled into view', async () => {
+ const el = render(OFFSCREEN);
+ await observed();
+ expect(el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted).toBeUndefined();
+
+ el.setAttribute('style', ONSCREEN);
+ await observed();
+ expect(el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted).toBe(true);
+ });
+
+ it('unmounts once scrolled back out of view', async () => {
+ const el = render(ONSCREEN);
+ await observed();
+ expect(el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted).toBe(true);
+
+ el.setAttribute('style', OFFSCREEN);
+ await observed();
+ expect(el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted).toBe(false);
+ });
+});
diff --git a/packages/v4/migration/AnchorNav/AnchorNavTarget.ts b/packages/v4/migration/AnchorNav/AnchorNavTarget.ts
new file mode 100644
index 000000000..89fbe8897
--- /dev/null
+++ b/packages/v4/migration/AnchorNav/AnchorNavTarget.ts
@@ -0,0 +1,14 @@
+import { Base, type BaseConfig } from '../../src/index.js';
+
+/**
+ * Marks a section `AnchorNav` tracks: mounts once scrolled into view and
+ * unmounts once it leaves, so `AnchorNav` can toggle the matching link.
+ *
+ * @link https://ui.studiometa.dev/reference/items/AnchorNav/
+ */
+export class AnchorNavTarget extends Base {
+ static config: BaseConfig = {
+ name: 'AnchorNavTarget',
+ mountStrategy: 'in-view',
+ };
+}
diff --git a/packages/v4/migration/AnchorNav/index.ts b/packages/v4/migration/AnchorNav/index.ts
new file mode 100644
index 000000000..490ed0d64
--- /dev/null
+++ b/packages/v4/migration/AnchorNav/index.ts
@@ -0,0 +1,3 @@
+export { AnchorNav, type AnchorNavProps } from './AnchorNav.js';
+export { AnchorNavLink, type AnchorNavLinkProps } from './AnchorNavLink.js';
+export { AnchorNavTarget } from './AnchorNavTarget.js';
diff --git a/packages/v4/migration/Carousel/Indexable.ts b/packages/v4/migration/Carousel/Indexable.ts
index 53e9dcda4..02cfe21e5 100644
--- a/packages/v4/migration/Carousel/Indexable.ts
+++ b/packages/v4/migration/Carousel/Indexable.ts
@@ -2,11 +2,6 @@ import { Base, type BaseConfig, type BaseProps } from '../../src/index.js';
import { clamp, fold, wrap } from '../../src/utils/maths.js';
import { randomInt } from '../../src/utils/random.js';
-/** Gap 10: core ships no `$warn`. */
-function warn(...args: unknown[]): void {
- console.warn('[Indexable]', ...args);
-}
-
export const INDEXABLE_BOUNDARIES = Object.freeze({
CLAMP: 'clamp',
LOOP: 'loop',
@@ -173,13 +168,13 @@ export class Indexable extends Base {
it('warns for a binding type that names nothing', async () => {
const details: string[] = [];
- document.addEventListener(EVENTS.diagnostic, (event) => {
+ // Removed at the end: this listener cancels the default sink, and leaking
+ // it silenced every later diagnostic in the file.
+ const listener = (event: Event) => {
const { detail } = event as CustomEvent<{ code: string; message: string }>;
details.push(detail.code);
event.preventDefault();
- });
+ };
+ document.addEventListener(EVENTS.diagnostic, listener);
const root = await render(`
{
// The typo used to be an attribute that silently did nothing at all.
expect(details).toContain('attribute.unknown-qualifier');
expect(at(root, '#d', 'DataBind').hasVirtualBindings).toBe(false);
+
+ document.removeEventListener(EVENTS.diagnostic, listener);
});
it('fails quietly when a virtual expression throws', async () => {
diff --git a/packages/v4/migration/Data/DataBind.ts b/packages/v4/migration/Data/DataBind.ts
index 1ff11d739..7a00b968b 100644
--- a/packages/v4/migration/Data/DataBind.ts
+++ b/packages/v4/migration/Data/DataBind.ts
@@ -39,10 +39,6 @@ export type DataBindProps = BaseProps & {
$options: DataBindOptions;
};
-function warn(...args: unknown[]): void {
- console.warn('[data]', ...args);
-}
-
/**
* The namespace a virtual binding is declared by. Its qualifier head is finite
* — the six binding types — while the name a `prop`, `attr`, `class` or `style`
@@ -370,7 +366,8 @@ export class DataBind
const { target } = this;
if (!(target instanceof HTMLTemplateElement)) {
- warn(
+ this.$warn(
+ 'data-bind.invalid-if-target',
'The data-bind:if binding can only be used on a element. Use data-bind:attr.hidden to show or hide an element in place.',
);
return;
@@ -412,7 +409,10 @@ export class DataBind
return true;
}
- warn(`The ${method}() method can not be used with this component.`);
+ this.$warn(
+ 'data-bind.unsupported-mutation',
+ `The ${method}() method can not be used with this component.`,
+ );
return false;
}
@@ -426,7 +426,10 @@ export class DataBind
isCheckbox(this.target) && (typeof onValue !== 'boolean' || typeof offValue !== 'boolean');
if (isRadio || hasCustomCheckboxValues) {
- warn('The toggle() values can not be represented by this input.');
+ this.$warn(
+ 'data-bind.unrepresentable-toggle',
+ 'The toggle() values can not be represented by this input.',
+ );
return;
}
@@ -439,7 +442,10 @@ export class DataBind
}
if (isInput(this.target) && this.target.type === 'date') {
- warn('The increment() method can not be used with date inputs.');
+ this.$warn(
+ 'data-bind.unsupported-mutation',
+ 'The increment() method can not be used with date inputs.',
+ );
return;
}
diff --git a/packages/v4/migration/Fetch/Fetch.spec.ts b/packages/v4/migration/Fetch/Fetch.spec.ts
index 5172820c6..a26aca986 100644
--- a/packages/v4/migration/Fetch/Fetch.spec.ts
+++ b/packages/v4/migration/Fetch/Fetch.spec.ts
@@ -100,6 +100,36 @@ function stubViewTransition(): { spy: ReturnType; restore: () => v
};
}
+describe('Fetch — headers across every HeadersInit form', () => {
+ it('forwards a caller Headers instance instead of dropping it', async () => {
+ const client = stubClient();
+ const { instance } = await mountFetch(
+ `
`,
+ );
+
+ await instance.fetch(instance.url, { headers: [['X-Custom', '1']] });
+ await settle();
+
+ expect(fetchPartials).not.toHaveBeenCalled();
+ expect(client).toHaveBeenCalledOnce();
+ });
+
+ it('still uses partial rendering for an internal header given as a Headers instance', async () => {
+ const client = stubClient();
+ const fetchPartials = vi.fn(async () => ({}));
+ stubPartials({ fetch: fetchPartials, apply: vi.fn() });
+ const { instance } = await mount(
+ ``,
+ );
+
+ await instance.fetch(instance.url, { headers: new Headers({ 'X-Requested-By': 'x' }) });
+ await settle();
+
+ expect(fetchPartials).toHaveBeenCalledOnce();
+ expect(client).not.toHaveBeenCalled();
+ });
+
+ it('routes an apply() rejection through the error event instead of leaving it unhandled', async () => {
+ stubClient();
+ const failure = new Error('apply failed');
+ stubPartials({
+ fetch: async () => ({}),
+ apply: () => Promise.reject(failure),
+ });
+ const { root, instance } = await mount(
+ ``,
+ );
+ const errors: unknown[] = [];
+ root.addEventListener(FETCH_EVENTS.ERROR, (event) => {
+ errors.push((event as CustomEvent<{ error: unknown }>).detail.error);
+ });
+
+ await instance.fetch();
+ await settle();
+
+ expect(errors).toEqual([failure]);
+ });
+
+ it('skips the history push for a popstate header given as a Headers instance', async () => {
+ stubPartials({ fetch: async () => ({}), apply: vi.fn() });
+ const { instance } = await mount(
+ ``,
+ );
+ const before = window.history.length;
+
+ // The internal header is what tells `applyPartials()` not to push; read as
+ // a plain record it is invisible in this form.
+ await instance.fetch(instance.url, {
+ headers: new Headers({ 'x-triggered-by': 'popstate' }),
+ });
+ await settle();
+
+ expect(window.history.length).toBe(before);
+ });
+
+ it('still pushes history for a request that is not popstate-triggered', async () => {
+ stubPartials({ fetch: async () => ({}), apply: vi.fn() });
+ const { instance } = await mount(
+ ``,
+ );
+ const before = window.history.length;
+
+ await instance.fetch();
+ await settle();
+
+ expect(window.history.length).toBe(before + 1);
+ });
+
+ it('memoises the resolved partials module across calls', async () => {
+ const loadSpy = vi.fn(async () => ({
+ partials: { fetch: vi.fn(async () => ({})), apply: vi.fn() },
+ }));
+ FetchShopifyPartial.loadPartialsModule = loadSpy;
+ const { instance } = await mount(
+ ``,
+ );
+
+ await instance.fetch();
+ await instance.fetch();
+
+ expect(loadSpy).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/v4/migration/Fetch/FetchShopifyPartial.ts b/packages/v4/migration/Fetch/FetchShopifyPartial.ts
new file mode 100644
index 000000000..72ac8f245
--- /dev/null
+++ b/packages/v4/migration/Fetch/FetchShopifyPartial.ts
@@ -0,0 +1,235 @@
+import { component, type BaseProps } from '../../src/index.js';
+import { historyPush } from '../../src/utils/history.js';
+import {
+ FETCH_EVENTS,
+ Fetch,
+ HEADER_NAMES,
+ headerNames,
+ headerValue,
+ type FetchProps,
+} from './Fetch.js';
+
+/** Minimal shape of the `partials` API exposed by `@shopify/partial-rendering`. */
+interface PartialsApi {
+ fetch(
+ ...args: [...names: string[], options: { url: string; signal?: AbortSignal }]
+ ): Promise;
+ apply(update: unknown): void | Promise;
+}
+
+/** Minimal shape of the `@shopify/partial-rendering` module. */
+interface PartialsModule {
+ partials: PartialsApi;
+}
+
+export type FetchShopifyPartialProps = FetchProps & {
+ $options: FetchProps['$options'] & { partials: string };
+};
+
+/**
+ * Adapts {@link Fetch} to Shopify's `@shopify/partial-rendering` API (Liquid
+ * July '26 preview). Partial rendering engages only when partial names are
+ * configured via the `partials` option **and** the preview package
+ * resolves; otherwise it transparently falls back to the base {@link Fetch}
+ * behaviour (id-based full-page swap).
+ *
+ * Compared to the base lifecycle, the partials path diverges in two ways:
+ * the `RESPONSE` event never fires (there is no `Response` object on this
+ * path), and the `UPDATE` payload carries the opaque partials `update`
+ * object instead of a parsed `Document` fragment — `partials.apply` owns DOM
+ * swapping, View Transitions and focus/selection/form/scroll preservation.
+ *
+ * @link https://ui.studiometa.dev/reference/items/Fetch/
+ */
+@component({
+ name: 'FetchShopifyPartial',
+ options: {
+ partials: String,
+ },
+})
+export class FetchShopifyPartial extends Fetch<
+ FetchShopifyPartialProps & T
+> {
+ /**
+ * Module specifier for the Shopify partial rendering package. A static
+ * field, not a module constant like {@link FETCH_EVENTS}: this one exists
+ * to be overridden, by a test or a subclass, so it keeps the shape a
+ * `this.constructor` access needs.
+ */
+ static PARTIALS_MODULE = '@shopify/partial-rendering';
+
+ /**
+ * Load the Shopify partial rendering module, lazily so the class compiles
+ * and runs without the preview package installed. Override on a subclass
+ * or reassign directly (`FetchShopifyPartial.loadPartialsModule = …`) to
+ * inject a fake.
+ */
+ static async loadPartialsModule(): Promise {
+ // Through `unknown`: a dynamic import of a non-literal specifier is `any`,
+ // and the shape is asserted rather than known — `resolvePartials()` is what
+ // turns a module that does not match into a `null` fallback.
+ const loaded: unknown = await import(/* @vite-ignore */ this.PARTIALS_MODULE);
+ return loaded as PartialsModule;
+ }
+
+ /** `undefined` means resolution has not been attempted yet, `null` means it failed. */
+ partialsModule: PartialsApi | null | undefined;
+
+ /** The configured partial names, trimmed and empty-filtered. */
+ get partialNames(): string[] {
+ return this.$options.partials
+ .split(',')
+ .map((partial) => partial.trim())
+ .filter(Boolean);
+ }
+
+ /**
+ * Resolve the partials API, memoising the result. Returns `null` on any
+ * failure (missing package, missing export, …) so callers fall back to
+ * the base behaviour. This never rejects.
+ */
+ async resolvePartials(): Promise {
+ if (typeof this.partialsModule !== 'undefined') {
+ return this.partialsModule;
+ }
+
+ try {
+ const ctor = this.constructor as typeof FetchShopifyPartial;
+ const loaded = await ctor.loadPartialsModule();
+ this.partialsModule = loaded?.partials ?? null;
+ } catch {
+ this.partialsModule = null;
+ }
+
+ return this.partialsModule;
+ }
+
+ /**
+ * Whether the given request can be expressed through the partials API.
+ *
+ * `@shopify/partial-rendering` only performs a GET for a URL — it takes
+ * nothing but `{ url, signal }` — so a request carrying a body, a
+ * non-GET method, custom headers or any other `RequestInit` field falls
+ * back to the base {@link Fetch} behaviour, whether these come from the
+ * element options or from the per-call `requestInit` argument.
+ * Framework-internal headers are ignored, so the declarative click,
+ * submit and popstate flows still use partial rendering.
+ */
+ canUsePartials(requestInit: RequestInit): boolean {
+ const method = requestInit.method ?? this.requestInit.method ?? 'get';
+
+ if (method.toLowerCase() !== 'get' || requestInit.body || this.requestInit.body) {
+ return false;
+ }
+
+ const supportedKeys = new Set(['method', 'headers', 'body', 'signal']);
+ for (const key of Object.keys({ ...this.$options.requestInit, ...requestInit })) {
+ if (!supportedKeys.has(key)) {
+ return false;
+ }
+ }
+
+ const internalHeaders = new Set(Object.values(HEADER_NAMES));
+ const declared = [
+ ...headerNames(this.requestInit.headers),
+ ...headerNames(requestInit.headers),
+ ];
+ for (const header of declared) {
+ if (!internalHeaders.has(header)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** Fetch via Shopify partial rendering when configured, otherwise fall back to the base behaviour. */
+ async fetch(url: URL | string = this.url, requestInit: RequestInit = {}): Promise {
+ const normalizedUrl = url instanceof URL ? url : new URL(url, window.location.href);
+ const names = this.partialNames;
+ const partials =
+ names.length && this.canUsePartials(requestInit) ? await this.resolvePartials() : null;
+
+ if (!partials) {
+ return super.fetch(normalizedUrl, requestInit);
+ }
+
+ this.$emit(FETCH_EVENTS.BEFORE_FETCH, { instance: this, url: normalizedUrl, requestInit });
+
+ this.abortController.abort();
+ const newController = new AbortController();
+ newController.signal.addEventListener('abort', () => {
+ this.$emit(FETCH_EVENTS.ABORT, {
+ instance: this,
+ url: normalizedUrl,
+ requestInit,
+ reason: newController.signal.reason,
+ });
+ });
+ this.abortController = newController;
+ const init = this.mergeRequestInit(requestInit, newController.signal);
+
+ this.$emit(FETCH_EVENTS.FETCH, { instance: this, url: normalizedUrl, requestInit: init });
+
+ try {
+ const update = await partials.fetch(...names, {
+ url: normalizedUrl.toString(),
+ signal: init.signal ?? undefined,
+ });
+ this.$emit(FETCH_EVENTS.AFTER_FETCH, {
+ instance: this,
+ url: normalizedUrl,
+ requestInit: init,
+ content: update,
+ });
+ // Fire-and-forget the apply phase, matching the base `Fetch.fetch`
+ // lifecycle: an `apply()` failure must not be misattributed to the
+ // fetch phase and re-emit `AFTER_FETCH` a second time. It still needs a
+ // `catch`, or a rejected Shopify DOM update is an unhandled rejection
+ // with no observable failure at all.
+ void this.applyPartials(normalizedUrl, init, update, partials).catch(
+ (applyError: unknown) => {
+ this.error(normalizedUrl, init, applyError as Error);
+ },
+ );
+ } catch (error) {
+ this.$emit(FETCH_EVENTS.AFTER_FETCH, {
+ instance: this,
+ url: normalizedUrl,
+ requestInit: init,
+ error,
+ });
+ this.error(normalizedUrl, init, error as Error);
+ }
+ }
+
+ /**
+ * Apply the partials update to the DOM. Kept separate from the base
+ * {@link Fetch.update}, which is still used verbatim on the fallback
+ * path: on the partials path, `partials.apply` owns DOM swapping, View
+ * Transitions and focus/selection/form/scroll preservation, so no
+ * fragment parsing happens here.
+ */
+ async applyPartials(
+ url: URL,
+ requestInit: RequestInit,
+ update: unknown,
+ partials: PartialsApi,
+ ): Promise {
+ const { history } = this.$options;
+
+ this.$emit(FETCH_EVENTS.BEFORE_UPDATE, { instance: this, url, requestInit, content: update });
+
+ if (history) {
+ if (headerValue(requestInit.headers, HEADER_NAMES.X_TRIGGERED_BY) !== 'popstate') {
+ historyPush({ path: url.pathname, search: url.searchParams });
+ }
+ }
+
+ this.$emit(FETCH_EVENTS.UPDATE, { instance: this, url, requestInit, update });
+
+ await partials.apply(update);
+
+ this.$emit(FETCH_EVENTS.AFTER_UPDATE, { instance: this, url, requestInit, update });
+ }
+}
diff --git a/packages/v4/migration/Fetch/index.ts b/packages/v4/migration/Fetch/index.ts
index 10b13dd77..266eb4ccf 100644
--- a/packages/v4/migration/Fetch/index.ts
+++ b/packages/v4/migration/Fetch/index.ts
@@ -6,6 +6,7 @@ export {
type FetchEventBase,
type FetchProps,
} from './Fetch.js';
+export { FetchShopifyPartial, type FetchShopifyPartialProps } from './FetchShopifyPartial.js';
export {
FetchShopifySection,
SECTIONS_PARAMETER,
diff --git a/packages/v4/migration/Figure/AbstractFigure.spec.ts b/packages/v4/migration/Figure/AbstractFigure.spec.ts
new file mode 100644
index 000000000..74282eff4
--- /dev/null
+++ b/packages/v4/migration/Figure/AbstractFigure.spec.ts
@@ -0,0 +1,95 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { registerComponents } from '../../src/index.js';
+import { getInstance, resetDom, settle } from '../../src/test-utils.js';
+import { Figure } from './Figure.js';
+
+registerComponents(Figure);
+
+afterEach(resetDom);
+
+const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px';
+const ONSCREEN = 'position:absolute;top:0;left:0;width:50px;height:50px';
+
+// A real 1x1 transparent PNG, so `loadImage()` succeeds without network access.
+const PIXEL =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
+
+async function observed(): Promise {
+ for (let i = 0; i < 6; i += 1) {
+ await settle();
+ }
+}
+
+/** Poll for a transition's kept end state; see `MenuList.spec.ts` for why. */
+async function waitForClass(el: HTMLElement, className: string, timeout = 1000): Promise {
+ const deadline = Date.now() + timeout;
+ while (!el.classList.contains(className)) {
+ if (Date.now() > deadline) {
+ throw new Error(`waitForClass: "${className}" never landed on the element`);
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+}
+
+// A tiny 1x1 white pixel, distinct from PIXEL, standing in for a placeholder.
+const PLACEHOLDER =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
+
+function render(
+ style: string,
+ attributes = 'data-option-lazy="true"',
+): {
+ el: HTMLElement;
+ img: HTMLImageElement;
+} {
+ const root = document.createElement('div');
+ root.innerHTML = `
+
+
+
`;
+ document.body.append(root);
+ const el = root.firstElementChild as HTMLElement;
+ return { el, img: el.querySelector('[data-ref="img"]') as HTMLImageElement };
+}
+
+describe('Figure (AbstractFigure)', () => {
+ it('loads the data-src once scrolled into view, emitting load', async () => {
+ const { el, img } = render(OFFSCREEN);
+ const events: unknown[] = [];
+ el.addEventListener('load', () => events.push(1));
+
+ await observed();
+ expect(events).toEqual([]);
+ expect(img.src).toBe(PLACEHOLDER);
+
+ el.setAttribute('style', ONSCREEN);
+ await observed();
+
+ expect(events).toEqual([1]);
+ expect(img.src).toBe(PIXEL);
+ });
+
+ it('does not load when the `lazy` option is not set', async () => {
+ const { el } = render(ONSCREEN, '');
+ const events: unknown[] = [];
+ el.addEventListener('load', () => events.push(1));
+
+ await observed();
+
+ expect(events).toEqual([]);
+ });
+
+ it('runs the enter transition once loaded', async () => {
+ const { el, img } = render(
+ ONSCREEN,
+ 'data-option-lazy="true" data-option-enter-to="visible" data-option-enter-keep="true"',
+ );
+
+ await observed();
+
+ // Polled, not assumed: `mounted()` fire-and-forgets the transition, and a
+ // kept end state lands a few frames after the image has loaded.
+ await waitForClass(img, 'visible');
+ expect(getInstance(el, 'Figure').state).toBe('entering');
+ });
+});
diff --git a/packages/v4/migration/Figure/AbstractFigure.ts b/packages/v4/migration/Figure/AbstractFigure.ts
new file mode 100644
index 000000000..9edbe5725
--- /dev/null
+++ b/packages/v4/migration/Figure/AbstractFigure.ts
@@ -0,0 +1,75 @@
+import { Base, type BaseConfig, type BaseProps } from '../../src/index.js';
+import { loadImage } from '../../src/utils/load.js';
+import { withTransition, type TransitionProps } from '../Transition/index.js';
+
+export type AbstractFigureProps = BaseProps &
+ TransitionProps & {
+ $refs: { img: HTMLImageElement };
+ $options: TransitionProps['$options'] & { lazy: boolean };
+ $emits: TransitionProps['$emits'] & { load: void };
+ };
+
+/**
+ * Shared base for the image figure components. It implements
+ * `withTransition` around a single `img` ref and, through the `in-view`
+ * mount strategy, defers loading of the `data-src` source until the element
+ * enters the viewport when the `lazy` option is set, running the enter
+ * transition and emitting `load` once the image is ready.
+ *
+ * v3 mixed `withMountWhenInView` onto `Transition`, whose transition half is
+ * now `withTransition` here. The `target` override is the whole reason the
+ * mixin has one: the transition runs on the image, not on the root.
+ */
+export class AbstractFigure extends withTransition(Base)<
+ AbstractFigureProps & T
+> {
+ static config: BaseConfig = {
+ name: 'AbstractFigure',
+ refs: ['img'],
+ mountStrategy: 'in-view',
+ options: {
+ lazy: Boolean,
+ },
+ };
+
+ get target(): HTMLElement {
+ return this.$refs.img;
+ }
+
+ get src(): string {
+ return this.$refs.img.src;
+ }
+
+ set src(value: string) {
+ this.$refs.img.src = value;
+ }
+
+ get original(): string {
+ return this.$refs.img.dataset.src ?? '';
+ }
+
+ /** Load on mount. */
+ async mounted(): Promise {
+ const { img } = this.$refs;
+
+ if (!img || !(img instanceof HTMLImageElement)) {
+ this.$warn('figure.invalid-ref', 'The `img` ref is missing or not an `` element.');
+ return;
+ }
+
+ const src = this.original;
+
+ if (this.$options.lazy && src && src !== this.src) {
+ try {
+ await loadImage(src);
+ } catch (error) {
+ this.$error('figure.load-failed', `Failed to load image "${src}".`, error);
+ return;
+ }
+
+ this.src = src;
+ void this.enter();
+ this.$emit('load');
+ }
+ }
+}
diff --git a/packages/v4/migration/Figure/AbstractFigureDynamic.ts b/packages/v4/migration/Figure/AbstractFigureDynamic.ts
new file mode 100644
index 000000000..870f94490
--- /dev/null
+++ b/packages/v4/migration/Figure/AbstractFigureDynamic.ts
@@ -0,0 +1,66 @@
+import { withResize, type BaseConfig, type BaseProps } from '../../src/index.js';
+import { loadImage } from '../../src/utils/load.js';
+import { AbstractFigure, type AbstractFigureProps } from './AbstractFigure.js';
+
+export type AbstractFigureDynamicProps = AbstractFigureProps & {
+ $options: AbstractFigureProps['$options'] & { disable: boolean; step: number };
+};
+
+/**
+ * Shared base for figures whose source is computed at runtime from the
+ * element's rendered size. It extends `AbstractFigure`, defaults the `lazy`
+ * option to `true`, and passes the original `data-src` through the
+ * overridable `formatSrc` method, unless the `disable` option is set. Its
+ * own `formatSrc` returns the source unchanged, so subclasses provide the
+ * actual transformation, and on resize it recomputes and reloads the
+ * source.
+ */
+export class AbstractFigureDynamic extends withResize(
+ AbstractFigure,
+) {
+ static config: BaseConfig = {
+ ...AbstractFigure.config,
+ name: 'AbstractFigureDynamic',
+ options: {
+ ...AbstractFigure.config.options,
+ disable: Boolean,
+ step: { type: Number, default: 50 },
+ lazy: { type: Boolean, default: true },
+ },
+ };
+
+ /** The formatted source, or the original based on the `disable` option. */
+ get original(): string {
+ return this.$options.disable ? super.original : this.formatSrc(super.original);
+ }
+
+ /** Format the source with dynamic parameters. */
+ formatSrc(src: string): string {
+ return src;
+ }
+
+ /**
+ * Reassign the source from the original on resize.
+ *
+ * `ResizeHook.resized` is declared to return `void`, so the reload is
+ * started rather than awaited: handing the service a promise it does not
+ * consume is what `no-misused-promises` is about.
+ */
+ resized(): void {
+ void this.reloadSource();
+ }
+
+ /** Recompute the formatted source and swap it in once it has loaded. */
+ async reloadSource(): Promise {
+ const { original } = this;
+
+ try {
+ await loadImage(original);
+ } catch (error) {
+ this.$error('figure.load-failed', `Failed to load image "${original}".`, error);
+ return;
+ }
+
+ this.src = original;
+ }
+}
diff --git a/packages/v4/migration/Figure/Figure.ts b/packages/v4/migration/Figure/Figure.ts
new file mode 100644
index 000000000..9f05e70b9
--- /dev/null
+++ b/packages/v4/migration/Figure/Figure.ts
@@ -0,0 +1,25 @@
+import type { BaseConfig, BaseProps } from '../../src/index.js';
+import { AbstractFigure, type AbstractFigureProps } from './AbstractFigure.js';
+
+export type FigureProps = AbstractFigureProps;
+
+/**
+ * Concrete lazy-loaded image figure built on `AbstractFigure`. It loads the
+ * `data-src` source when the element scrolls into view, running the enter
+ * transition and emitting `load` once it is ready.
+ *
+ * v3's `onLoad()` called `$terminate()`, since it has no further work to do
+ * after the reveal — v4 has no termination (the `LazyInclude` port hit the
+ * same gap). It needs none here either: `AbstractFigure.mounted()` only
+ * loads when `src !== this.src`, which is already false once loaded, so a
+ * later remount (the `in-view` strategy can trigger one) is a no-op on its
+ * own.
+ *
+ * @link https://ui.studiometa.dev/reference/items/Figure/
+ */
+export class Figure extends AbstractFigure {
+ static config: BaseConfig = {
+ ...AbstractFigure.config,
+ name: 'Figure',
+ };
+}
diff --git a/packages/v4/migration/Figure/FigureShopify.spec.ts b/packages/v4/migration/Figure/FigureShopify.spec.ts
new file mode 100644
index 000000000..5ed41958b
--- /dev/null
+++ b/packages/v4/migration/Figure/FigureShopify.spec.ts
@@ -0,0 +1,66 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { registerComponents } from '../../src/index.js';
+import { getInstance, resetDom, settle } from '../../src/test-utils.js';
+import { FigureShopify } from './FigureShopify.js';
+
+registerComponents(FigureShopify);
+
+afterEach(resetDom);
+
+// `lazy` is left unset (defaults false on a plain Figure, but
+// AbstractFigureDynamic defaults it true) so it is disabled explicitly:
+// `formatSrc` is tested as a pure function, and mounting must not attempt a
+// real network fetch against a fabricated CDN URL.
+async function render(attributes = ''): Promise {
+ const root = document.createElement('div');
+ root.innerHTML = `
+
+
+
`;
+ document.body.append(root);
+ await settle();
+ return getInstance(root.firstElementChild, 'FigureShopify');
+}
+
+describe('FigureShopify', () => {
+ it('sizes the source to the rendered element, rounded to the step', async () => {
+ const instance = await render('data-option-step="50"');
+
+ const url = new URL(instance.formatSrc('https://cdn.shopify.com/shop/product.jpg'));
+
+ expect(url.searchParams.get('width')).toBe(String(100 * window.devicePixelRatio));
+ expect(url.searchParams.get('height')).toBe(String(200 * window.devicePixelRatio));
+ });
+
+ it('rounds a size up to the next step', async () => {
+ const instance = await render('data-option-step="150"');
+
+ const url = new URL(instance.formatSrc('https://cdn.shopify.com/shop/product.jpg'));
+
+ // 100 -> 150, 200 -> 300, per `normalizeSize`.
+ expect(url.searchParams.get('width')).toBe(String(150 * window.devicePixelRatio));
+ expect(url.searchParams.get('height')).toBe(String(300 * window.devicePixelRatio));
+ });
+
+ it('sets the crop parameter when the option is given', async () => {
+ const instance = await render('data-option-crop="center"');
+
+ const url = new URL(instance.formatSrc('https://cdn.shopify.com/shop/product.jpg'));
+
+ expect(url.searchParams.get('crop')).toBe('center');
+ });
+
+ it('omits the crop parameter by default', async () => {
+ const instance = await render();
+
+ const url = new URL(instance.formatSrc('https://cdn.shopify.com/shop/product.jpg'));
+
+ expect(url.searchParams.has('crop')).toBe(false);
+ });
+
+ it('bypasses formatSrc when disabled', async () => {
+ const instance = await render('data-option-disable');
+
+ expect(instance.original).toBe('https://cdn.shopify.com/shop/product.jpg');
+ });
+});
diff --git a/packages/v4/migration/Figure/FigureShopify.ts b/packages/v4/migration/Figure/FigureShopify.ts
new file mode 100644
index 000000000..60b601970
--- /dev/null
+++ b/packages/v4/migration/Figure/FigureShopify.ts
@@ -0,0 +1,47 @@
+import type { BaseConfig, BaseProps } from '../../src/index.js';
+import { AbstractFigureDynamic, type AbstractFigureDynamicProps } from './AbstractFigureDynamic.js';
+import { normalizeSize } from './utils.js';
+
+export type FigureShopifyProps = AbstractFigureDynamicProps & {
+ $options: AbstractFigureDynamicProps['$options'] & {
+ crop?: 'top' | 'left' | 'right' | 'bottom' | 'center';
+ };
+};
+
+/**
+ * Dynamic image figure that rewrites its source for the Shopify CDN,
+ * sized to the rendered element.
+ *
+ * @link https://shopify.dev/docs/api/liquid/filters/image_url
+ * @link https://ui.studiometa.dev/reference/items/FigureShopify/
+ */
+export class FigureShopify extends AbstractFigureDynamic<
+ FigureShopifyProps & T
+> {
+ static config: BaseConfig = {
+ ...AbstractFigureDynamic.config,
+ name: 'FigureShopify',
+ options: {
+ ...AbstractFigureDynamic.config.options,
+ crop: String,
+ },
+ };
+
+ /** Format the source for Shopify CDN API. */
+ formatSrc(src: string): string {
+ const { crop, step } = this.$options;
+
+ const url = new URL(src, 'https://localhost');
+ const width = normalizeSize(this.$refs.img.offsetWidth, step) * window.devicePixelRatio;
+ const height = normalizeSize(this.$refs.img.offsetHeight, step) * window.devicePixelRatio;
+
+ url.searchParams.set('width', String(width));
+ url.searchParams.set('height', String(height));
+
+ if (crop) {
+ url.searchParams.set('crop', crop);
+ }
+
+ return url.toString();
+ }
+}
diff --git a/packages/v4/migration/Figure/FigureTwicpics.spec.ts b/packages/v4/migration/Figure/FigureTwicpics.spec.ts
new file mode 100644
index 000000000..a165b0421
--- /dev/null
+++ b/packages/v4/migration/Figure/FigureTwicpics.spec.ts
@@ -0,0 +1,80 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { registerComponents } from '../../src/index.js';
+import { getInstance, resetDom, settle } from '../../src/test-utils.js';
+import { FigureTwicpics } from './FigureTwicpics.js';
+
+registerComponents(FigureTwicpics);
+
+afterEach(resetDom);
+
+// `lazy` disabled for the same reason as the FigureShopify spec: `formatSrc`
+// is a pure function under test, and mounting must not fetch a fabricated URL.
+async function render(
+ attributes = '',
+ src = 'https://example.com/original/photo.jpg',
+): Promise {
+ const root = document.createElement('div');
+ root.innerHTML = `
+
+
+
`;
+ document.body.append(root);
+ await settle();
+ return getInstance(root.firstElementChild, 'FigureTwicpics');
+}
+
+describe('FigureTwicpics', () => {
+ it('builds a twic query from the measured size and the default cover mode', async () => {
+ const instance = await render('data-option-step="50"');
+
+ const url = new URL(instance.formatSrc('https://example.com/original/photo.jpg'));
+
+ expect(url.searchParams.get('twic')).toBe(
+ `v1/cover=${100 * window.devicePixelRatio}x${200 * window.devicePixelRatio}`,
+ );
+ });
+
+ it('includes the transform ahead of the mode when given', async () => {
+ const instance = await render('data-option-transform="my-transform" data-option-step="50"');
+
+ const url = new URL(instance.formatSrc('https://example.com/original/photo.jpg'));
+
+ expect(url.searchParams.get('twic')).toBe(
+ `v1/my-transform/cover=${100 * window.devicePixelRatio}x${200 * window.devicePixelRatio}`,
+ );
+ });
+
+ it('defaults the domain to the source host', async () => {
+ const instance = await render();
+
+ expect(instance.domain).toBe('example.com');
+ });
+
+ it('uses the domain option over the source host when given', async () => {
+ const instance = await render('data-option-domain="cdn.twic.pics"');
+
+ const url = new URL(instance.formatSrc('https://example.com/original/photo.jpg'));
+
+ expect(url.host).toBe('cdn.twic.pics');
+ });
+
+ it('prefixes the pathname with the path option, without a doubled slash', async () => {
+ const instance = await render('data-option-path="/my/base/"');
+
+ expect(instance.path).toBe('my/base');
+ const url = new URL(instance.formatSrc('https://example.com/original/photo.jpg'));
+ expect(url.pathname).toBe('/my/base/original/photo.jpg');
+ });
+
+ it('reports device pixel ratio 1 when dpr is disabled', async () => {
+ const instance = await render('data-option-no-dpr');
+
+ expect(instance.devicePixelRatio).toBe(1);
+ });
+
+ it('reports the real device pixel ratio by default', async () => {
+ const instance = await render();
+
+ expect(instance.devicePixelRatio).toBe(window.devicePixelRatio);
+ });
+});
diff --git a/packages/v4/migration/Figure/FigureTwicpics.ts b/packages/v4/migration/Figure/FigureTwicpics.ts
new file mode 100644
index 000000000..7ce2b5b29
--- /dev/null
+++ b/packages/v4/migration/Figure/FigureTwicpics.ts
@@ -0,0 +1,94 @@
+import type { BaseConfig, BaseProps } from '../../src/index.js';
+import {
+ withLeadingSlash,
+ withoutLeadingSlash,
+ withoutTrailingSlash,
+} from '../../src/utils/strings.js';
+import { AbstractFigureDynamic, type AbstractFigureDynamicProps } from './AbstractFigureDynamic.js';
+import { normalizeSize } from './utils.js';
+
+export type FigureTwicpicsProps = AbstractFigureDynamicProps & {
+ $options: AbstractFigureDynamicProps['$options'] & {
+ transform: string;
+ domain: string;
+ path: string;
+ mode: string;
+ dpr: boolean;
+ };
+};
+
+/** Whether the user agent is a bot. */
+const isBot = /bot|crawl|slurp|spider/i.test(navigator.userAgent);
+
+/**
+ * Dynamic image figure that rewrites its source into a TwicPics URL sized to
+ * the rendered element. Its `formatSrc` injects a `twic` query built from
+ * the `domain`, `path`, `transform` and `mode` options and the measured
+ * dimensions, multiplied by the device pixel ratio unless `dpr` is disabled
+ * or a bot is detected.
+ *
+ * @link https://ui.studiometa.dev/reference/items/FigureTwicpics/
+ */
+export class FigureTwicpics extends AbstractFigureDynamic<
+ FigureTwicpicsProps & T
+> {
+ static config: BaseConfig = {
+ ...AbstractFigureDynamic.config,
+ name: 'FigureTwicpics',
+ options: {
+ ...AbstractFigureDynamic.config.options,
+ transform: String,
+ domain: String,
+ path: String,
+ mode: { type: String, default: 'cover' },
+ dpr: { type: Boolean, default: true },
+ },
+ };
+
+ /** The TwicPics path. */
+ get path(): string {
+ return withoutTrailingSlash(withoutLeadingSlash(this.$options.path));
+ }
+
+ /** The TwicPics domain. */
+ get domain(): string {
+ return this.$options.domain || new URL(this.$refs.img.dataset.src ?? '').host;
+ }
+
+ /**
+ * The current device pixel ratio. `1` for a bot, and `1` when `dpr` is
+ * disabled (`data-option-no-dpr`, since it defaults `true`).
+ */
+ get devicePixelRatio(): number {
+ if (!this.$options.dpr || isBot) {
+ return 1;
+ }
+
+ return window.devicePixelRatio;
+ }
+
+ /** Format the source for TwicPics. */
+ formatSrc(src: string): string {
+ const { transform, mode, step } = this.$options;
+
+ const url = new URL(src, 'https://localhost');
+ url.host = this.domain;
+ url.port = '';
+
+ if (this.path && !url.pathname.startsWith(withLeadingSlash(this.path))) {
+ url.pathname = `/${this.path}${url.pathname}`;
+ }
+
+ const width = normalizeSize(this.$refs.img.offsetWidth, step) * this.devicePixelRatio;
+ const height = normalizeSize(this.$refs.img.offsetHeight, step) * this.devicePixelRatio;
+
+ url.searchParams.set(
+ 'twic',
+ ['v1', transform, `${mode}=${width}x${height}`].filter(Boolean).join('/'),
+ );
+
+ url.search = decodeURIComponent(url.search);
+
+ return url.toString();
+ }
+}
diff --git a/packages/v4/migration/Figure/index.ts b/packages/v4/migration/Figure/index.ts
new file mode 100644
index 000000000..d253c9197
--- /dev/null
+++ b/packages/v4/migration/Figure/index.ts
@@ -0,0 +1,5 @@
+export { AbstractFigure, type AbstractFigureProps } from './AbstractFigure.js';
+export { AbstractFigureDynamic, type AbstractFigureDynamicProps } from './AbstractFigureDynamic.js';
+export { Figure, type FigureProps } from './Figure.js';
+export { FigureShopify, type FigureShopifyProps } from './FigureShopify.js';
+export { FigureTwicpics, type FigureTwicpicsProps } from './FigureTwicpics.js';
diff --git a/packages/v4/migration/Figure/utils.ts b/packages/v4/migration/Figure/utils.ts
new file mode 100644
index 000000000..ee280d433
--- /dev/null
+++ b/packages/v4/migration/Figure/utils.ts
@@ -0,0 +1,4 @@
+/** Normalize a size to the given step. */
+export function normalizeSize(size: number, step: number): number {
+ return Math.ceil(size / step) * step;
+}
diff --git a/packages/v4/migration/FigureVideo/FigureVideo.spec.ts b/packages/v4/migration/FigureVideo/FigureVideo.spec.ts
new file mode 100644
index 000000000..4a16110eb
--- /dev/null
+++ b/packages/v4/migration/FigureVideo/FigureVideo.spec.ts
@@ -0,0 +1,131 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { registerComponents } from '../../src/index.js';
+import { getInstance, resetDom, settle } from '../../src/test-utils.js';
+import { FigureVideo } from './FigureVideo.js';
+
+registerComponents(FigureVideo);
+
+afterEach(resetDom);
+
+const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px';
+const ONSCREEN = 'position:absolute;top:0;left:0;width:50px;height:50px';
+
+// A real 1x1 PNG, so `loadImage()` succeeds without network access.
+const PIXEL =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
+
+async function observed(): Promise {
+ for (let i = 0; i < 6; i += 1) {
+ await settle();
+ }
+}
+
+function render(
+ style: string,
+ attributes = 'data-option-lazy="true"',
+): { el: HTMLElement; video: HTMLVideoElement } {
+ const root = document.createElement('div');
+ root.innerHTML = `
+
+
+
`;
+ document.body.append(root);
+ const el = root.firstElementChild as HTMLElement;
+ return { el, video: el.querySelector('[data-ref="video"]') as HTMLVideoElement };
+}
+
+/**
+ * `loadSources()` waits on the real `loadeddata` event, which a data-URI
+ * `` may never fire in a headless browser. Dispatching it directly
+ * is the same technique used elsewhere in this migration to drive a
+ * component's logic without depending on real media decoding.
+ */
+function fireLoadedData(video: HTMLVideoElement): void {
+ video.dispatchEvent(new Event('loadeddata'));
+}
+
+describe('FigureVideo', () => {
+ it('loads the poster and sources once scrolled into view, emitting load', async () => {
+ const { el, video } = render(OFFSCREEN);
+ const events: unknown[] = [];
+ el.addEventListener('load', () => events.push(1));
+
+ await observed();
+ expect(events).toEqual([]);
+ expect(video.querySelector('source')?.src).toBe('');
+
+ el.setAttribute('style', ONSCREEN);
+ await settle();
+ fireLoadedData(video);
+ await observed();
+
+ expect(events).toEqual([1]);
+ expect(video.querySelector('source')?.src).toBe(PIXEL);
+ expect(video.poster).toBe(PIXEL);
+ });
+
+ it('does not load when the `lazy` option is not set', async () => {
+ const { el, video } = render(ONSCREEN, '');
+ const events: unknown[] = [];
+ el.addEventListener('load', () => events.push(1));
+
+ await observed();
+ fireLoadedData(video);
+ await observed();
+
+ expect(events).toEqual([]);
+ });
+
+ it('does not reload once already loaded', async () => {
+ const { el, video } = render(ONSCREEN);
+ await settle();
+ fireLoadedData(video);
+ await observed();
+
+ const instance = getInstance(el, 'FigureVideo');
+ const spy = vi.spyOn(instance, 'load');
+
+ // A later mount cycle on the same instance — the in-view strategy can
+ // trigger one — must not repeat the load.
+ await instance.mounted();
+
+ expect(spy).not.toHaveBeenCalled();
+ expect(instance.hasLoaded).toBe(true);
+ });
+
+ it('settles and reports when the sources fail, instead of hanging forever', async () => {
+ const { el, video } = render(ONSCREEN);
+ const details: Array> = [];
+ const listener = (event: Event) => {
+ details.push((event as CustomEvent>).detail);
+ event.preventDefault();
+ };
+ document.addEventListener('js-toolkit:diagnostic', listener);
+
+ await settle();
+ // v3 waits on `loadeddata` alone, so this never settled and `mounted()`
+ // never returned.
+ video.dispatchEvent(new Event('error'));
+ await observed();
+
+ expect(details.map((detail) => detail.code)).toContain('figure-video.load-failed');
+ // Left un-loaded, so a later mount cycle can retry.
+ expect(getInstance(el, 'FigureVideo').hasLoaded).toBe(false);
+
+ document.removeEventListener('js-toolkit:diagnostic', listener);
+ });
+
+ it('warns and does not throw when the video ref is missing', async () => {
+ const root = document.createElement('div');
+ root.innerHTML = ``;
+ document.body.append(root);
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ await expect(observed()).resolves.toBeUndefined();
+
+ expect(warnSpy).toHaveBeenCalled();
+ warnSpy.mockRestore();
+ });
+});
diff --git a/packages/v4/migration/FigureVideo/FigureVideo.ts b/packages/v4/migration/FigureVideo/FigureVideo.ts
new file mode 100644
index 000000000..14009d046
--- /dev/null
+++ b/packages/v4/migration/FigureVideo/FigureVideo.ts
@@ -0,0 +1,140 @@
+import { Base, type BaseConfig, type BaseProps } from '../../src/index.js';
+import { loadImage } from '../../src/utils/load.js';
+import { withTransition, type TransitionProps } from '../Transition/index.js';
+
+export type FigureVideoProps = BaseProps &
+ TransitionProps & {
+ $refs: { video: HTMLVideoElement };
+ $options: TransitionProps['$options'] & { lazy: boolean };
+ $emits: TransitionProps['$emits'] & { load: void };
+ };
+
+/**
+ * Lazy-loaded video counterpart to `Figure`. Mixing in `withTransition`
+ * (whose `target` it overrides onto the `video` ref, as `AbstractFigure`
+ * does onto its `img`) and mounting through the `in-view` strategy, it
+ * defers loading of the `video` ref's `data-poster` and `data-src` sources
+ * until the element enters the viewport when `lazy` is set, runs the enter
+ * transition, and emits `load`.
+ *
+ * @link https://ui.studiometa.dev/reference/items/FigureVideo/
+ */
+export class FigureVideo extends withTransition(Base)<
+ FigureVideoProps & T
+> {
+ static config: BaseConfig = {
+ name: 'FigureVideo',
+ refs: ['video'],
+ mountStrategy: 'in-view',
+ options: {
+ lazy: Boolean,
+ },
+ };
+
+ /**
+ * Whether the sources have already been loaded, so a later mount (the
+ * `in-view` strategy can trigger one) does not repeat it. v3 called
+ * `$terminate()` from `onLoad()` for this; v4 has no termination (the
+ * `Figure` and `LazyInclude` ports hit the same gap), and unlike `Figure`
+ * this component has no naturally idempotent check to fall back on —
+ * `load()` always reassigns every source — so the flag is load-bearing
+ * here, not just documentation.
+ */
+ hasLoaded = false;
+
+ get target(): HTMLVideoElement {
+ return this.$refs.video;
+ }
+
+ get sources(): HTMLSourceElement[] {
+ return [...this.$refs.video.querySelectorAll('source')];
+ }
+
+ /** Load the poster onto the video element. */
+ async loadPoster(): Promise {
+ const { video } = this.$refs;
+
+ if (!video.dataset.poster) {
+ return;
+ }
+
+ try {
+ await loadImage(video.dataset.poster);
+ video.poster = video.dataset.poster;
+ } catch (error) {
+ this.$error(
+ 'figure-video.poster-load-failed',
+ `Failed to load poster "${video.dataset.poster}".`,
+ error,
+ );
+ }
+ }
+
+ /** Load every ``'s `data-src` and wait for the video to have data. */
+ loadSources(): Promise {
+ const { video } = this.$refs;
+
+ for (const source of this.sources) {
+ if (source.dataset.src) {
+ source.src = source.dataset.src;
+ }
+ }
+
+ /**
+ * Settled by either outcome, deliberately.
+ *
+ * v3 waits on `loadeddata` alone, so a video whose sources all fail never
+ * settles at all — and because `mounted()` awaits it, the component then
+ * never reaches its enter transition, its `load` event or `hasLoaded`. A
+ * media error is a real outcome and has to end the wait.
+ */
+ return new Promise((resolve, reject) => {
+ const settle = (handler: () => void) => {
+ video.removeEventListener('loadeddata', onLoaded);
+ video.removeEventListener('error', onError);
+ handler();
+ };
+ const onLoaded = () => settle(resolve);
+ const onError = () =>
+ settle(() => reject(new Error(`Failed to load the sources of "${video.currentSrc}".`)));
+
+ video.addEventListener('loadeddata', onLoaded, { once: true });
+ video.addEventListener('error', onError, { once: true });
+ video.load();
+ });
+ }
+
+ load(): Promise<[void, void]> {
+ return Promise.all([this.loadPoster(), this.loadSources()]);
+ }
+
+ /** Load on mount, once per element while lazy is set. */
+ async mounted(): Promise {
+ const { video } = this.$refs;
+
+ if (!video || !(video instanceof HTMLVideoElement)) {
+ this.$warn(
+ 'figure-video.invalid-ref',
+ 'The `video` ref is missing or not a `
`;
+}
+
+// Off-screen: this test environment's real Chromium can deliver a genuine
+// `mouseenter` wherever the cursor happens to rest by default, and content
+// rendered at the top of `document.body` is where it lands.
+async function render(mode?: string): Promise<{ root: HTMLElement; menu: Menu }> {
+ const root = document.createElement('div');
+ root.setAttribute('style', 'position:absolute;top:300vh;left:0');
+ root.innerHTML = menuMarkup(mode);
+ document.body.append(root);
+ await settle();
+ return { root, menu: getInstance