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
1 change: 1 addition & 0 deletions docs/advanced/selective-hydration.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Uses `requestIdleCallback` internally when available and falls back to `setTimeo
Current behavior:

- If `deferUntilIdle` is used by itself, Askr delays the hydration pass until the idle callback fires.
- `skipSelectors` does not change that timing: permanent skips are marked before the same idle-delayed hydration pass.
- If it is combined with `deferBelowFold`, Askr hydrates the visible shell first and can activate deferred below-fold regions during the later idle pass.

### 3. Skip Static Content
Expand Down
3 changes: 2 additions & 1 deletion docs/core/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ previous value available. A mutation-driven invalidation commits `pending-write`
moves to `refreshing` when the confirming fetch starts.
`staleReason` narrows settled stale states into `inconsistent`, `aborted`, or `error`.
Manual calls to `refresh()` coalesce while a request is pending. `invalidate()`
is the distinct operation that replaces stale work. A `reconcile` callback may
is the distinct operation that replaces stale work; rapid invalidations before
the replacement begins coalesce into the latest queued refresh. A `reconcile` callback may
be async; its decision is awaited before any retry is scheduled, and a thrown
consistency or reconciliation callback becomes a terminal stale error.
The key also defines the query contract itself. If multiple readers use the same key, or one
Expand Down
4 changes: 4 additions & 0 deletions docs/internals/foundations-pit-of-success.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ function Menu() {
}
```

Horizontal roving focus follows the computed text direction of the event target:
Arrow Right advances in LTR and Arrow Left advances in RTL. Vertical navigation
is unchanged.

### NO PREVENTED: Custom arrow key handling

```typescript
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ may return native promises or compatible promise-like values. Decisions are
awaited in declaration order. During client navigation, an auth result that
settles after its request was aborted by a newer navigation is discarded, so
`currentAuth()` continues to describe the navigation that actually committed.
During server rendering it is scoped to the request render context, including
deferred streaming boundaries, so concurrent requests cannot replace it.

## `fallback(Component)`

Expand Down
21 changes: 17 additions & 4 deletions src/actions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
resolveDataRuntimeState,
} from '../data/data-runtime';
import { readActionFramework } from './runtime';
import { isCurrentAsyncGeneration } from '../data/shared';

const actionSubmissionGenerations = new WeakMap<object, number>();

Expand Down Expand Up @@ -164,15 +165,27 @@ export function action<
(prefix): prefix is string => typeof prefix === 'string'
)
: descriptor.invalidates;
if (generation === actionSubmissionGenerations.get(value)) {
for (const prefix of invalidates)
invalidateQueriesForRuntime(runtime, prefix, true);
// Every successful server mutation invalidates its confirmed prefixes,
// even when a newer submission owns the visible action state.
for (const prefix of invalidates)
invalidateQueriesForRuntime(runtime, prefix, true);
if (
isCurrentAsyncGeneration(
actionSubmissionGenerations.get(value) ?? 0,
generation
)
) {
setValue({ pending: false, result: envelope.result });
if (redirect) location.assign(redirect);
}
return envelope.result as TResult;
} catch (error) {
if (generation === actionSubmissionGenerations.get(value)) {
if (
isCurrentAsyncGeneration(
actionSubmissionGenerations.get(value) ?? 0,
generation
)
) {
setValue({ pending: false, error });
}
throw error;
Expand Down
4 changes: 1 addition & 3 deletions src/boot/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,7 @@ export async function applySelectiveHydration(
hooks: HydrationRuntimeHooks,
interactionReplay: HydrationInteractionReplay
): Promise<void> {
const hasPermanentSkips = (hydrateOptions.skipSelectors?.length ?? 0) > 0;
const hasBelowFoldDeferral = !!hydrateOptions.deferBelowFold;
const hasSelectiveBoundaries = hasPermanentSkips || hasBelowFoldDeferral;
let staticChildSlotsCacheSuspended = false;
let releaseSelectiveHydrationResources = () => {};

Expand Down Expand Up @@ -302,7 +300,7 @@ export async function applySelectiveHydration(
window.addEventListener('scroll', handleScroll, { passive: true });
}

if (hydrateOptions.deferUntilIdle && !hasSelectiveBoundaries) {
if (hydrateOptions.deferUntilIdle && !hasBelowFoldDeferral) {
await queueIdleWork(() => {
hooks.mountOrUpdate(
rootElement,
Expand Down
26 changes: 21 additions & 5 deletions src/data/mutation-cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import {
createReadableSource,
isAbortError,
isCurrentAsyncOperation,
normalizeAsyncDataError,
notifySource,
} from './shared';
Expand Down Expand Up @@ -93,7 +94,14 @@ export class MutationCell<TInput, TResult> {
try {
result = await action(input, { signal: controller.signal });
} catch (error) {
if (this.generation !== generation || this.controller !== controller) {
if (
!isCurrentAsyncOperation(
this.generation,
generation,
this.controller,
controller
)
) {
throw error;
}

Expand All @@ -109,12 +117,20 @@ export class MutationCell<TInput, TResult> {
throw error;
}

if (this.generation !== generation || this.controller !== controller) {
return result;
const isCurrent = isCurrentAsyncOperation(
this.generation,
generation,
this.controller,
controller
);
if (isCurrent) {
// Commit visible mutation state before marking affected queries so the
// pending-write frame remains observable to subscribers.
this.setState({ status: 'success', error: null, result });
}

this.setState({ status: 'success', error: null, result });

// A superseded operation may still commit remotely when its action ignores
// AbortSignal. Its successful effects must still invalidate cached data.
if (afterSuccess === 'invalidate') {
const prefixes = affects?.(input, result) ?? [];
for (const prefix of new Set(prefixes)) {
Expand Down
84 changes: 71 additions & 13 deletions src/data/query-cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { getDefaultDataRuntime } from './data-runtime';
import {
createReadableSource,
isAbortError,
isCurrentAsyncOperation,
normalizeAsyncDataError,
notifySource,
} from './shared';
Expand Down Expand Up @@ -46,6 +47,7 @@ export class QueryCell<T> {
private pendingRefreshResolve: (() => void) | null = null;
private pendingRefreshToken = 0;
private reconcileAttemptCount = 0;
private reconcileSequence = 0;
private destroyed = false;
private ownerCount = 0;
private readonly owners = new Map<object, Set<number>>();
Expand Down Expand Up @@ -296,22 +298,31 @@ export class QueryCell<T> {
});
}

private queueStart(): void {
private queueStart(reconcileSequence?: number): void {
if (this.destroyed) {
return;
}

const sequence =
reconcileSequence ??
(() => {
this.reconcileAttemptCount = 0;
return ++this.reconcileSequence;
})();
this.startQueued = true;
const token = ++this.pendingRefreshToken;
this.pendingRefresh = new Promise<void>((resolve) => {
this.pendingRefreshResolve = resolve;
enqueueRuntimeTask(() => {
if (token !== this.pendingRefreshToken) {
return;
}
this.startQueued = false;
if (this.destroyed) {
this.finishPendingRefresh(token);
return;
}
void this.start().finally(() => {
void this.start(sequence).finally(() => {
this.finishPendingRefresh(token);
});
});
Expand Down Expand Up @@ -340,7 +351,7 @@ export class QueryCell<T> {
notifySource(this.source);
}

private async start(): Promise<void> {
private async start(reconcileSequence: number): Promise<void> {
if (this.destroyed) {
return;
}
Expand Down Expand Up @@ -368,8 +379,12 @@ export class QueryCell<T> {
} catch (error) {
if (
this.destroyed ||
this.generation !== generation ||
this.controller !== controller
!isCurrentAsyncOperation(
this.generation,
generation,
this.controller,
controller
)
) {
return;
}
Expand Down Expand Up @@ -410,8 +425,12 @@ export class QueryCell<T> {

if (
this.destroyed ||
this.generation !== generation ||
this.controller !== controller
!isCurrentAsyncOperation(
this.generation,
generation,
this.controller,
controller
)
) {
return;
}
Expand Down Expand Up @@ -440,9 +459,21 @@ export class QueryCell<T> {
staleReason: 'inconsistent',
});
try {
await this.reconcile(nextData);
await this.reconcile(
nextData,
generation,
controller,
reconcileSequence
);
} catch (error) {
if (this.generation === generation && this.controller === controller) {
if (
isCurrentAsyncOperation(
this.generation,
generation,
this.controller,
controller
)
) {
this.setState({
loading: false,
refreshing: false,
Expand Down Expand Up @@ -471,12 +502,27 @@ export class QueryCell<T> {
});
}

private async reconcile(data: T): Promise<void> {
private async reconcile(
data: T,
generation: number,
controller: AbortController,
reconcileSequence: number
): Promise<void> {
const shouldRetry = await (this.options.reconcile?.(data, {
key: this.options.key,
}) ?? false);

if (!shouldRetry || this.destroyed) {
if (
!shouldRetry ||
this.destroyed ||
reconcileSequence !== this.reconcileSequence ||
!isCurrentAsyncOperation(
this.generation,
generation,
this.controller,
controller
)
) {
return;
}

Expand All @@ -493,13 +539,25 @@ export class QueryCell<T> {
await new Promise<void>((resolve) =>
setTimeout(resolve, RECONCILE_RETRY_DELAY_MS)
);
if (this.destroyed || this.state.consistency === 'fresh') {
if (
this.destroyed ||
reconcileSequence !== this.reconcileSequence ||
this.state.consistency === 'fresh' ||
!isCurrentAsyncOperation(
this.generation,
generation,
this.controller,
controller
)
) {
return;
}

// Reconciliation runs inside the current refresh promise. It must replace
// that generation rather than coalesce with itself as a manual refresh.
this.invalidate();
this.controller?.abort();
this.finishPendingRefresh(this.pendingRefreshToken);
this.queueStart(reconcileSequence);
}
}

Expand Down
21 changes: 21 additions & 0 deletions src/data/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ export function isAbortError(error: unknown, signal: AbortSignal): boolean {
);
}

/** Whether an async result still belongs to the latest generation. */
export function isCurrentAsyncGeneration(
currentGeneration: number,
capturedGeneration: number
): boolean {
return currentGeneration === capturedGeneration;
}

/** Whether an async result still owns both its generation and controller. */
export function isCurrentAsyncOperation(
currentGeneration: number,
capturedGeneration: number,
currentController: AbortController | null,
capturedController: AbortController
): boolean {
return (
isCurrentAsyncGeneration(currentGeneration, capturedGeneration) &&
currentController === capturedController
);
}

export function normalizeAsyncDataError(
error: unknown,
fallbackMessage: string
Expand Down
15 changes: 13 additions & 2 deletions src/foundations/interactions/roving-focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,9 @@ export function rovingFocus(options: RovingFocusOptions): RovingFocusResult {
let direction: 1 | -1 | undefined;

if (orientation === 'horizontal' || orientation === 'both') {
if (key === 'ArrowRight') direction = 1;
if (key === 'ArrowLeft') direction = -1;
const textDirection = resolveTextDirection(e);
if (key === 'ArrowRight') direction = textDirection === 'rtl' ? -1 : 1;
if (key === 'ArrowLeft') direction = textDirection === 'rtl' ? 1 : -1;
}

if (orientation === 'vertical' || orientation === 'both') {
Expand Down Expand Up @@ -178,6 +179,16 @@ export function rovingFocus(options: RovingFocusOptions): RovingFocusResult {
};
}

function resolveTextDirection(event: KeyboardLikeEvent): 'ltr' | 'rtl' {
const candidate = event.currentTarget ?? event.target;
if (typeof Element === 'undefined' || !(candidate instanceof Element)) {
return 'ltr';
}
const direction =
candidate.ownerDocument.defaultView?.getComputedStyle(candidate).direction;
return direction === 'rtl' ? 'rtl' : 'ltr';
}

/**
* USAGE EXAMPLE:
*
Expand Down
5 changes: 3 additions & 2 deletions src/foundations/utilities/compose-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ export function setRef<T>(ref: Ref<T>, value: T | null): void {
ref(value);
return;
}
// Fast path: use Object.isExtensible check instead of try/catch for better performance
if (Object.isExtensible(ref)) {
try {
(ref as { current: T | null }).current = value;
} catch {
// Readonly object refs are intentionally ignored so later composed refs run.
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/foundations/utilities/event-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface PropagationStoppable {
export interface KeyboardLikeEvent
extends DefaultPreventable, PropagationStoppable {
key: string;
currentTarget?: unknown;
target?: unknown;
}

/** Structural subset of a pointer event, for handlers that accept native or synthetic events. */
Expand Down
Loading