diff --git a/tests/jsdom/foundations/roving-focus-rtl.test.ts b/tests/jsdom/foundations/roving-focus-rtl.test.ts new file mode 100644 index 00000000..3c80947a --- /dev/null +++ b/tests/jsdom/foundations/roving-focus-rtl.test.ts @@ -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(); + }); +}); diff --git a/tests/jsdom/operations/mutation-invalidate-superseded.test.ts b/tests/jsdom/operations/mutation-invalidate-superseded.test.ts new file mode 100644 index 00000000..40e826ce --- /dev/null +++ b/tests/jsdom/operations/mutation-invalidate-superseded.test.ts @@ -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((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'); + }); +}); diff --git a/tests/jsdom/operations/reconcile-sequence-guard.test.ts b/tests/jsdom/operations/reconcile-sequence-guard.test.ts new file mode 100644 index 00000000..8bd45f11 --- /dev/null +++ b/tests/jsdom/operations/reconcile-sequence-guard.test.ts @@ -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>(); + 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 { + await Promise.resolve(); + await Promise.resolve(); + flushScheduler(); + await Promise.resolve(); +} diff --git a/tests/jsdom/renderer/dom-range-ownership.test.ts b/tests/jsdom/renderer/dom-range-ownership.test.ts new file mode 100644 index 00000000..a01ea19e --- /dev/null +++ b/tests/jsdom/renderer/dom-range-ownership.test.ts @@ -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); + }); +});