Skip to content
Merged
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
57 changes: 57 additions & 0 deletions tests/jsdom/foundations/roving-focus-rtl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vite-plus/test';
import { rovingFocus } from '@askrjs/askr/foundations/interactions';

describe('rovingFocus RTL arrow-key direction (regression for #357)', () => {
it('should invert ArrowLeft to move forward and ArrowRight to move backward when computed direction is rtl', () => {
const container = document.createElement('div');
container.style.direction = 'rtl';
document.body.append(container);
const onNavigate = vi.fn();
const navigation = rovingFocus({
currentIndex: 1,
itemCount: 3,
onNavigate,
});

navigation.container.onKeyDown({
key: 'ArrowLeft',
currentTarget: container,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
// In RTL, ArrowLeft moves toward higher indices (visually forward).
expect(onNavigate).toHaveBeenLastCalledWith(2);

navigation.container.onKeyDown({
key: 'ArrowRight',
currentTarget: container,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
// In RTL, ArrowRight moves toward lower indices (visually backward).
expect(onNavigate).toHaveBeenLastCalledWith(0);

container.remove();
});

it('should keep default LTR arrow-key direction when no rtl styling is present', () => {
const container = document.createElement('div');
document.body.append(container);
const onNavigate = vi.fn();
const navigation = rovingFocus({
currentIndex: 1,
itemCount: 3,
onNavigate,
});

navigation.container.onKeyDown({
key: 'ArrowRight',
currentTarget: container,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
expect(onNavigate).toHaveBeenLastCalledWith(2);

container.remove();
});
});
54 changes: 54 additions & 0 deletions tests/jsdom/operations/mutation-invalidate-superseded.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it } from 'vite-plus/test';
import { createMutation, getDefaultDataRuntime } from '../../../src/data';

afterEach(() => {
getDefaultDataRuntime().queryData.clear();
});

describe('mutation-cell invalidate-even-when-superseded (regression for #357)', () => {
it("should invalidate the superseded submission's own affected prefix using its own input/result, without letting it overwrite the visible mutation state", async () => {
let resolveFirst!: (value: string) => void;
let resolveSecond!: (value: string) => void;
getDefaultDataRuntime().queryData.set('item:a:detail', { stale: true });
getDefaultDataRuntime().queryData.set('item:b:detail', { stale: true });

const mutation = createMutation({
action: (input: string) =>
new Promise<string>((resolve) => {
if (input === 'a') resolveFirst = resolve;
else resolveSecond = resolve;
}),
affects: (input: string, result: string) => {
expect(result).toBe(`committed-${input}`);
return [`item:${input}:`];
},
afterSuccess: 'invalidate',
});

const first = mutation.execute('a');
const second = mutation.execute('b');

// The superseded ('a') submission commits remotely after being
// overtaken by 'b'. It must still invalidate its own affected prefix.
resolveFirst('committed-a');
await expect(first).resolves.toBe('committed-a');

// Its own prefix is invalidated using the superseded call's own
// input/result pair, not the newer generation's.
expect(getDefaultDataRuntime().queryData.has('item:a:detail')).toBe(false);
// The unrelated prefix for the still-pending current submission must be
// untouched by the superseded submission's invalidation.
expect(getDefaultDataRuntime().queryData.has('item:b:detail')).toBe(true);

// Crucially, the superseded submission's success must NOT overwrite the
// visible mutation state, which still belongs to the current ('b') op.
expect(mutation.status).toBe('pending');
expect(mutation.result).toBe(null);

resolveSecond('committed-b');
await expect(second).resolves.toBe('committed-b');
expect(getDefaultDataRuntime().queryData.has('item:b:detail')).toBe(false);
expect(mutation.status).toBe('success');
expect(mutation.result).toBe('committed-b');
});
});
78 changes: 78 additions & 0 deletions tests/jsdom/operations/reconcile-sequence-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
import { QueryCell } from '../../../src/data/query-cell';
import { flushScheduler } from '../../../test-utils/render/test-renderer';

afterEach(() => {
vi.useRealTimers();
});

describe('reconcile sequence guard (regression for #357)', () => {
it('should not let a stale reconcile retry timer restart an in-flight superseded fetch while the cell is still stale (not fresh)', async () => {
vi.useFakeTimers();
const pending: Array<(value: { version: number }) => void> = [];
const fetch = vi.fn(
() =>
new Promise<{ version: number }>((resolve) => {
pending.push(resolve);
})
);
const cache = new Map<string, QueryCell<unknown>>();
const cell = new QueryCell(
{
key: 'sequence-guard',
fetch,
isConsistent: (value) => value.version > 2,
reconcile: () => true,
},
'sequence-guard',
cache
);
const owner = {};
cell.attach(owner, 0);
try {
// Fetch #1: inconsistent -> schedules reconcile retry timer A
// (captured sequence 1).
void cell.refresh();
flushScheduler();
pending[0]!({ version: 1 });
await settle();
expect(fetch).toHaveBeenCalledTimes(1);

// Superseded before timer A fires: fetch #2 starts (sequence 2) but
// has NOT resolved yet -> the cell's consistency is still 'refreshing'
// (not 'fresh'), so a naive "only skip when fresh" guard would not
// protect this window.
cell.invalidate();
flushScheduler();
expect(fetch).toHaveBeenCalledTimes(2);
expect(cell.consistency).not.toBe('fresh');

// Now let stale timer A fire while fetch #2 is still in flight.
// The correct behavior is to no-op: timer A belongs to a superseded
// reconcile sequence, and fetch #2 must be left completely alone.
await vi.advanceTimersByTimeAsync(25);
flushScheduler();
await settle();

// No extra fetch was started, and fetch #2's own controller/promise
// was not aborted or clobbered by timer A.
expect(fetch).toHaveBeenCalledTimes(2);

// fetch #2 can still resolve normally and reach a fresh state.
pending[1]!({ version: 3 });
await settle();
expect(cell.data).toEqual({ version: 3 });
expect(cell.consistency).toBe('fresh');
expect(fetch).toHaveBeenCalledTimes(2);
} finally {
cell.detach(owner, 0);
}
});
});

async function settle(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
flushScheduler();
await Promise.resolve();
}
49 changes: 49 additions & 0 deletions tests/jsdom/renderer/dom-range-ownership.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vite-plus/test';
import {
createSingleNodeRange,
getOwnedRange,
getRangeOwner,
registerRange,
} from '../../../src/renderer/dom-range';

describe('dom-range ownership reassignment (regression for #357)', () => {
it("should release the previous owner's registration when a shared anchor node is re-registered under a new owner", () => {
const node = document.createElement('div');
const previousOwner = {};
const nextOwner = {};

const firstRange = createSingleNodeRange(node, previousOwner);
expect(getOwnedRange(previousOwner)).toBe(firstRange);
expect(getRangeOwner(node)).toBe(previousOwner);

// A new range object sharing the same start/end anchor node gets
// registered under a different owner (e.g. re-parented control).
const secondRange = { start: node, end: node, single: true } as const;
registerRange(secondRange, nextOwner);

// The anchor now belongs to the new owner...
expect(getRangeOwner(node)).toBe(nextOwner);
expect(getOwnedRange(nextOwner)).toEqual(secondRange);

// ...and critically, the previous owner's stale registration must be
// released, not left pointing at a range whose anchor it no longer owns.
expect(getOwnedRange(previousOwner)).toBeUndefined();
});

it("should NOT release an unrelated owner's range that happens to occupy the same WeakMap chain but shares no anchor node", () => {
const nodeA = document.createElement('div');
const nodeB = document.createElement('span');
const ownerA = {};
const ownerB = {};

const rangeA = createSingleNodeRange(nodeA, ownerA);
const rangeB = createSingleNodeRange(nodeB, ownerB);

// Re-registering ownerB's own range again should never disturb ownerA,
// since they share no start/end anchor.
registerRange(rangeB, ownerB);

expect(getOwnedRange(ownerA)).toBe(rangeA);
expect(getOwnedRange(ownerB)).toBe(rangeB);
});
});