From 9b5d5ad4bd5e0c7f942d4069a9dc21cb4d3c63c3 Mon Sep 17 00:00:00 2001
From: Jeff Repanich
Date: Mon, 24 Aug 2026 13:12:33 -0400
Subject: [PATCH] fix: harden cross-cutting runtime contracts
---
docs/advanced/selective-hydration.md | 1 +
docs/core/data.md | 3 +-
docs/internals/foundations-pit-of-success.md | 4 +
docs/reference/router.md | 2 +
src/actions/index.tsx | 21 +++-
src/boot/hydration.ts | 4 +-
src/data/mutation-cell.ts | 26 ++++-
src/data/query-cell.ts | 84 ++++++++++---
src/data/shared.ts | 21 ++++
src/foundations/interactions/roving-focus.ts | 15 ++-
src/foundations/utilities/compose-ref.ts | 5 +-
src/foundations/utilities/event-types.ts | 2 +
src/renderer/attributes.ts | 11 +-
src/renderer/cleanup.ts | 16 +--
src/renderer/dom-range.ts | 6 +-
src/renderer/retained-element-rollback.ts | 14 +--
src/ssr/context.ts | 2 +
src/ssr/render-sync.ts | 3 +
src/ssr/route-policy-resolution.ts | 8 ++
src/ssr/route-render.ts | 6 +
src/ssr/route-request-render.ts | 16 ++-
tests/jsdom/actions/actions.test.tsx | 47 ++++++++
tests/jsdom/dom/cold-create.test.tsx | 18 +++
tests/jsdom/foundations/compose-refs.test.ts | 15 +++
.../foundations/roving-focus-contract.test.ts | 22 ++++
.../adversarial-async-generations.test.ts | 110 ++++++++++++++++++
.../performance-optimizations.test.tsx | 5 +-
tests/jsdom/ssr/deferred-streaming.test.tsx | 71 ++++++++++-
tests/jsdom/ssr/hydration.test.tsx | 102 ++++++++++++++++
tests/jsdom/ssr/route-request-render.test.tsx | 17 ++-
30 files changed, 600 insertions(+), 77 deletions(-)
create mode 100644 tests/jsdom/operations/adversarial-async-generations.test.ts
diff --git a/docs/advanced/selective-hydration.md b/docs/advanced/selective-hydration.md
index 1267ebd9..5045a959 100644
--- a/docs/advanced/selective-hydration.md
+++ b/docs/advanced/selective-hydration.md
@@ -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
diff --git a/docs/core/data.md b/docs/core/data.md
index 384d1167..ccf4330a 100644
--- a/docs/core/data.md
+++ b/docs/core/data.md
@@ -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
diff --git a/docs/internals/foundations-pit-of-success.md b/docs/internals/foundations-pit-of-success.md
index 29b2376e..cdbe1b14 100644
--- a/docs/internals/foundations-pit-of-success.md
+++ b/docs/internals/foundations-pit-of-success.md
@@ -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
diff --git a/docs/reference/router.md b/docs/reference/router.md
index 9f70ce8b..534a24bc 100644
--- a/docs/reference/router.md
+++ b/docs/reference/router.md
@@ -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)`
diff --git a/src/actions/index.tsx b/src/actions/index.tsx
index 0f306c58..4b13521d 100644
--- a/src/actions/index.tsx
+++ b/src/actions/index.tsx
@@ -8,6 +8,7 @@ import {
resolveDataRuntimeState,
} from '../data/data-runtime';
import { readActionFramework } from './runtime';
+import { isCurrentAsyncGeneration } from '../data/shared';
const actionSubmissionGenerations = new WeakMap();
@@ -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;
diff --git a/src/boot/hydration.ts b/src/boot/hydration.ts
index 04debe83..a2ac6cec 100644
--- a/src/boot/hydration.ts
+++ b/src/boot/hydration.ts
@@ -231,9 +231,7 @@ export async function applySelectiveHydration(
hooks: HydrationRuntimeHooks,
interactionReplay: HydrationInteractionReplay
): Promise {
- const hasPermanentSkips = (hydrateOptions.skipSelectors?.length ?? 0) > 0;
const hasBelowFoldDeferral = !!hydrateOptions.deferBelowFold;
- const hasSelectiveBoundaries = hasPermanentSkips || hasBelowFoldDeferral;
let staticChildSlotsCacheSuspended = false;
let releaseSelectiveHydrationResources = () => {};
@@ -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,
diff --git a/src/data/mutation-cell.ts b/src/data/mutation-cell.ts
index 8bccce91..1c946018 100644
--- a/src/data/mutation-cell.ts
+++ b/src/data/mutation-cell.ts
@@ -10,6 +10,7 @@ import {
import {
createReadableSource,
isAbortError,
+ isCurrentAsyncOperation,
normalizeAsyncDataError,
notifySource,
} from './shared';
@@ -93,7 +94,14 @@ export class MutationCell {
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;
}
@@ -109,12 +117,20 @@ export class MutationCell {
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)) {
diff --git a/src/data/query-cell.ts b/src/data/query-cell.ts
index fb90e466..4b0a588d 100644
--- a/src/data/query-cell.ts
+++ b/src/data/query-cell.ts
@@ -16,6 +16,7 @@ import { getDefaultDataRuntime } from './data-runtime';
import {
createReadableSource,
isAbortError,
+ isCurrentAsyncOperation,
normalizeAsyncDataError,
notifySource,
} from './shared';
@@ -46,6 +47,7 @@ export class QueryCell {
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>();
@@ -296,22 +298,31 @@ export class QueryCell {
});
}
- 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((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);
});
});
@@ -340,7 +351,7 @@ export class QueryCell {
notifySource(this.source);
}
- private async start(): Promise {
+ private async start(reconcileSequence: number): Promise {
if (this.destroyed) {
return;
}
@@ -368,8 +379,12 @@ export class QueryCell {
} catch (error) {
if (
this.destroyed ||
- this.generation !== generation ||
- this.controller !== controller
+ !isCurrentAsyncOperation(
+ this.generation,
+ generation,
+ this.controller,
+ controller
+ )
) {
return;
}
@@ -410,8 +425,12 @@ export class QueryCell {
if (
this.destroyed ||
- this.generation !== generation ||
- this.controller !== controller
+ !isCurrentAsyncOperation(
+ this.generation,
+ generation,
+ this.controller,
+ controller
+ )
) {
return;
}
@@ -440,9 +459,21 @@ export class QueryCell {
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,
@@ -471,12 +502,27 @@ export class QueryCell {
});
}
- private async reconcile(data: T): Promise {
+ private async reconcile(
+ data: T,
+ generation: number,
+ controller: AbortController,
+ reconcileSequence: number
+ ): Promise {
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;
}
@@ -493,13 +539,25 @@ export class QueryCell {
await new Promise((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);
}
}
diff --git a/src/data/shared.ts b/src/data/shared.ts
index 35b41270..06cff2d4 100644
--- a/src/data/shared.ts
+++ b/src/data/shared.ts
@@ -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
diff --git a/src/foundations/interactions/roving-focus.ts b/src/foundations/interactions/roving-focus.ts
index 50632932..833d86d3 100644
--- a/src/foundations/interactions/roving-focus.ts
+++ b/src/foundations/interactions/roving-focus.ts
@@ -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') {
@@ -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:
*
diff --git a/src/foundations/utilities/compose-ref.ts b/src/foundations/utilities/compose-ref.ts
index e6d588dd..73b7375c 100644
--- a/src/foundations/utilities/compose-ref.ts
+++ b/src/foundations/utilities/compose-ref.ts
@@ -31,9 +31,10 @@ export function setRef(ref: Ref, 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.
}
}
diff --git a/src/foundations/utilities/event-types.ts b/src/foundations/utilities/event-types.ts
index 98ee25c0..a1854cd8 100644
--- a/src/foundations/utilities/event-types.ts
+++ b/src/foundations/utilities/event-types.ts
@@ -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. */
diff --git a/src/renderer/attributes.ts b/src/renderer/attributes.ts
index 9671674b..c4b508bd 100644
--- a/src/renderer/attributes.ts
+++ b/src/renderer/attributes.ts
@@ -3,6 +3,7 @@ import { isUnsafeUrlAttribute } from '../common/url';
import { isDevelopmentEnvironment } from '../common/env';
import { logger } from '../common/logger';
import { incrementPerfMetric } from '../runtime';
+import { setRef } from '../foundations/utilities/compose-ref';
import {
extractKey,
getRenderedAttributeName,
@@ -59,15 +60,7 @@ type Ref =
export function applyRef(el: T, ref: unknown): void {
const resolvedRef = ref as Ref;
- if (!resolvedRef) return;
- if (typeof resolvedRef === 'function') {
- resolvedRef(el);
- return;
- }
-
- if (Object.isExtensible(resolvedRef)) {
- (resolvedRef as { current: T | null }).current = el;
- }
+ setRef(resolvedRef, el);
}
export function applyFormControlProp(
diff --git a/src/renderer/cleanup.ts b/src/renderer/cleanup.ts
index 55bab74b..db736f60 100644
--- a/src/renderer/cleanup.ts
+++ b/src/renderer/cleanup.ts
@@ -6,6 +6,7 @@ import {
clearDelegatedHandlersForElement,
removeDelegatedListener,
} from '../runtime';
+import { setRef } from '../foundations/utilities/compose-ref';
type InstanceHost = Node & {
__ASKR_INSTANCE?: unknown;
@@ -53,20 +54,7 @@ export function replaceElementRefBookkeeping(
}
function applyRefValue(ref: unknown, value: T | null): void {
- const resolvedRef = ref as Ref;
-
- if (!resolvedRef) {
- return;
- }
-
- if (typeof resolvedRef === 'function') {
- resolvedRef(value);
- return;
- }
-
- if (Object.isExtensible(resolvedRef)) {
- (resolvedRef as { current: T | null }).current = value;
- }
+ setRef(ref as Ref, value);
}
export function updateElementRef(
diff --git a/src/renderer/dom-range.ts b/src/renderer/dom-range.ts
index f006b5b4..634e9037 100644
--- a/src/renderer/dom-range.ts
+++ b/src/renderer/dom-range.ts
@@ -128,10 +128,14 @@ export function registerRange(range: DOMRange, owner?: object): void {
const previousStartOwner = ownersByAnchor.get(range.start);
const previousEndOwner = ownersByAnchor.get(range.end);
for (const previousOwner of [previousStartOwner, previousEndOwner]) {
+ const previousRange = previousOwner
+ ? rangesByOwner.get(previousOwner)
+ : undefined;
if (
previousOwner &&
previousOwner !== owner &&
- rangesByOwner.get(previousOwner) === range
+ previousRange &&
+ (previousRange.start === range.start || previousRange.end === range.end)
) {
rangesByOwner.delete(previousOwner);
}
diff --git a/src/renderer/retained-element-rollback.ts b/src/renderer/retained-element-rollback.ts
index 051a5ae5..9d481605 100644
--- a/src/renderer/retained-element-rollback.ts
+++ b/src/renderer/retained-element-rollback.ts
@@ -1,4 +1,5 @@
import { logger } from '../common/logger';
+import { setRef, type Ref } from '../foundations/utilities/compose-ref';
import {
addDelegatedListener,
getDelegatedHandlersForElement,
@@ -217,19 +218,8 @@ function restoreFormControl(
}
function applyRefValue(ref: unknown, value: T | null): void {
- if (!ref) {
- return;
- }
-
try {
- if (typeof ref === 'function') {
- (ref as (value: T | null) => void)(value);
- return;
- }
-
- if (Object.isExtensible(ref)) {
- (ref as { current: T | null }).current = value;
- }
+ setRef(ref as Ref, value);
} catch {
// Rollback must preserve the original render error.
}
diff --git a/src/ssr/context.ts b/src/ssr/context.ts
index b6e33a9b..48668541 100644
--- a/src/ssr/context.ts
+++ b/src/ssr/context.ts
@@ -112,6 +112,7 @@ export function createRenderContext(
params?: Record;
routes?: readonly Route[];
routeAuth?: RouteAuthOptions;
+ authContext?: AuthContext;
basePath?: string;
signal?: AbortSignal;
dataRuntime?: unknown;
@@ -141,6 +142,7 @@ export function createRenderContext(
params: opts.params,
routes: opts.routes,
routeAuth: opts.routeAuth,
+ authContext: opts.authContext,
basePath: opts.basePath,
signal: opts.signal,
dataRuntime: opts.dataRuntime ?? createDataRuntime({ queryCache }),
diff --git a/src/ssr/render-sync.ts b/src/ssr/render-sync.ts
index fecb56d4..a6f74a03 100644
--- a/src/ssr/render-sync.ts
+++ b/src/ssr/render-sync.ts
@@ -639,6 +639,8 @@ export function renderToStringSync(
/** @internal A composed page render envelope. */
envelope?: import('../common/page-render-envelope').PageRenderEnvelope;
cspNonce?: string;
+ /** @internal Request-local authentication for deferred SSR passes. */
+ authContext?: import('@askrjs/auth').AuthContext;
/** @internal Capture request-local registrations produced by this pass. */
onContext?: (ctx: RenderContext) => void;
}
@@ -649,6 +651,7 @@ export function renderToStringSync(
data: options?.data,
envelope: options?.envelope,
cspNonce: nonce,
+ authContext: options?.authContext,
});
return withRenderContext(ctx, () => {
diff --git a/src/ssr/route-policy-resolution.ts b/src/ssr/route-policy-resolution.ts
index 3370a2c8..166e60b5 100644
--- a/src/ssr/route-policy-resolution.ts
+++ b/src/ssr/route-policy-resolution.ts
@@ -5,6 +5,8 @@ import type {
RouteRequestResult,
} from '../common/router';
import * as RouteModule from '../router/route';
+import { getRouteRenderContext } from '../router/resolution';
+import type { AuthContext } from '@askrjs/auth';
import { _resolveRouteMatchFromRoutes } from '../router/route-matching';
import { throwSSRDataMissing } from './context';
import type { RouteRenderOptions, SSRRoute } from './route-render';
@@ -19,6 +21,7 @@ type ResolvedPolicyAwareSSRRoute = {
url: string;
route: SSRRoute;
params: Record;
+ authContext?: AuthContext;
};
function getRouteRequestResultSync(
@@ -91,6 +94,10 @@ export function resolvePolicyAwareSSRRoute(
RouteModule.resolveRouteRequest(href, {
registry: opts.registry,
mode: 'ssr',
+ auth: opts.auth,
+ authContext: opts.authContext,
+ request: opts.request,
+ signal: opts.signal,
})
);
@@ -136,6 +143,7 @@ export function resolvePolicyAwareSSRRoute(
: matched.route.handler,
},
params: matched.params,
+ authContext: getRouteRenderContext(resolved)?.auth,
};
}
diff --git a/src/ssr/route-render.ts b/src/ssr/route-render.ts
index b80a2f6c..e39b0828 100644
--- a/src/ssr/route-render.ts
+++ b/src/ssr/route-render.ts
@@ -37,6 +37,9 @@ type SSRRouteSource = { registry: RouteRegistry };
export type RouteRenderOptions = SSRRouteSource & {
url: string;
+ auth?: RouteAuthOptions;
+ authContext?: AuthContext;
+ signal?: AbortSignal;
seed?: number;
data?: SSRData;
document?: DocumentRenderer;
@@ -138,8 +141,11 @@ function resolveSSRRouteRender(
data,
params: resolvedRoute.params,
routes: routeTable,
+ routeAuth: opts.auth ?? opts.registry.manifest.auth,
+ signal: opts.signal ?? opts.request?.signal,
dataRuntime: opts.dataRuntime,
queryPrefetch: opts.queryPrefetch,
+ authContext: resolvedRoute.authContext,
envelope: opts.envelope,
cspNonce,
});
diff --git a/src/ssr/route-request-render.ts b/src/ssr/route-request-render.ts
index e1618528..31b11ac0 100644
--- a/src/ssr/route-request-render.ts
+++ b/src/ssr/route-request-render.ts
@@ -99,7 +99,8 @@ function renderBoundary(
payload: unknown,
seed: number | undefined,
data: PageRenderEnvelope | null,
- cspNonce: string | undefined
+ cspNonce: string | undefined,
+ authContext: AuthContext | undefined
): { html: string; styles: SSRStyleRegistration[] } {
const styles: SSRStyleRegistration[] = [];
const html = renderToStringSync(
@@ -112,6 +113,7 @@ function renderBoundary(
seed,
envelope: data ?? undefined,
cspNonce,
+ authContext,
onContext: (context) => styles.push(...context.ssrStyles.values()),
}
);
@@ -156,7 +158,8 @@ function createDeferredRenderStream(
seed: number | undefined,
data: PageRenderEnvelope | null,
runtime: DataRuntime,
- cspNonce: string | undefined
+ cspNonce: string | undefined,
+ authContext: AuthContext | undefined
): ReadableStream {
const encoder = new TextEncoder();
const local = new AbortController();
@@ -201,7 +204,8 @@ function createDeferredRenderStream(
value,
seed,
data,
- cspNonce
+ cspNonce,
+ authContext
);
controller.enqueue(
encoder.encode(
@@ -226,7 +230,8 @@ function createDeferredRenderStream(
error,
seed,
data,
- cspNonce
+ cspNonce,
+ authContext
);
controller.enqueue(
encoder.encode(
@@ -352,7 +357,8 @@ async function renderRouteRequestInternal(
options.seed,
context.hydrationData,
runtime,
- cspNonce
+ cspNonce,
+ context.authContext
),
}
: {}),
diff --git a/tests/jsdom/actions/actions.test.tsx b/tests/jsdom/actions/actions.test.tsx
index 67223799..c9257e88 100644
--- a/tests/jsdom/actions/actions.test.tsx
+++ b/tests/jsdom/actions/actions.test.tsx
@@ -68,6 +68,53 @@ describe('actions', () => {
}
});
+ it('should invalidate a successful superseded submission without replacing newer visible state', async () => {
+ let resolveFirst!: (response: Response) => void;
+ let resolveSecond!: (response: Response) => void;
+ getDefaultDataRuntime().queryData.set('first:record', { stale: true });
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockImplementationOnce(
+ () => new Promise((resolve) => (resolveFirst = resolve))
+ )
+ .mockImplementationOnce(
+ () => new Promise((resolve) => (resolveSecond = resolve))
+ )
+ );
+ let command!: ReturnType<
+ typeof action<{ name: string }, { saved: string }>
+ >;
+ const App = () => {
+ command = action<{ name: string }, { saved: string }>(save);
+ return {command.state().result?.saved ?? 'idle'}
;
+ };
+ const { container, cleanup } = createTestContainer();
+ try {
+ createIsland({ root: container, component: App });
+ flushScheduler();
+ const first = command.submit({ name: 'first' });
+ const second = command.submit({ name: 'second' });
+
+ resolveFirst(
+ Response.json({
+ result: { saved: 'first' },
+ invalidates: ['first:'],
+ })
+ );
+ await first;
+ expect(getDefaultDataRuntime().queryData.has('first:record')).toBe(false);
+ expect(command.state().pending).toBe(true);
+
+ resolveSecond(Response.json({ result: { saved: 'second' } }));
+ await second;
+ expect(command.state().result).toEqual({ saved: 'second' });
+ } finally {
+ cleanup();
+ }
+ });
+
it('should ignore a stale action result after the first submission rerenders its owner', async () => {
let resolveFirst!: (response: Response) => void;
let resolveSecond!: (response: Response) => void;
diff --git a/tests/jsdom/dom/cold-create.test.tsx b/tests/jsdom/dom/cold-create.test.tsx
index d45d484d..8502cb5a 100644
--- a/tests/jsdom/dom/cold-create.test.tsx
+++ b/tests/jsdom/dom/cold-create.test.tsx
@@ -4,6 +4,7 @@ import { createDOMNode } from '../../../src/renderer/dom';
import {
createDetachedRange,
getOwnedRange,
+ registerRange,
} from '../../../src/renderer/dom-range';
import { state, type State } from '../../../src/runtime/state';
import { createIsland } from '../../../test-utils/render/create-island';
@@ -47,6 +48,23 @@ describe('cold DOM construction', () => {
expect(getOwnedRange(nextOwner)).toBe(first.range);
});
+ it('should transfer shared anchors when the next owner registers a fresh range object', () => {
+ const firstOwner = {};
+ const nextOwner = {};
+ const input = document.createDocumentFragment();
+ input.append(
+ document.createElement('span'),
+ document.createElement('span')
+ );
+ const first = createDetachedRange(input, firstOwner);
+ const replacement = { ...first.range };
+
+ registerRange(replacement, nextOwner);
+
+ expect(getOwnedRange(firstOwner)).toBeUndefined();
+ expect(getOwnedRange(nextOwner)).toBe(replacement);
+ });
+
it('should append multiple children directly into a detached intrinsic', () => {
const createFragment = vi.spyOn(document, 'createDocumentFragment');
diff --git a/tests/jsdom/foundations/compose-refs.test.ts b/tests/jsdom/foundations/compose-refs.test.ts
index ea2c8643..b8014a7a 100644
--- a/tests/jsdom/foundations/compose-refs.test.ts
+++ b/tests/jsdom/foundations/compose-refs.test.ts
@@ -25,4 +25,19 @@ describe('composeRefs (FOUNDATIONS)', () => {
expect(first.mock.calls).toEqual([[node], [null]]);
expect(second.mock.calls).toEqual([[node], [null]]);
});
+
+ it('should continue composing after a readonly object ref rejects assignment', () => {
+ const readonlyRef = {} as { current: { id: string } | null };
+ Object.defineProperty(readonlyRef, 'current', {
+ value: null,
+ writable: false,
+ configurable: true,
+ });
+ const callback = vi.fn();
+ const value = { id: 'reachable' };
+
+ expect(() => composeRefs(readonlyRef, callback)(value)).not.toThrow();
+ expect(readonlyRef.current).toBeNull();
+ expect(callback).toHaveBeenCalledWith(value);
+ });
});
diff --git a/tests/jsdom/foundations/roving-focus-contract.test.ts b/tests/jsdom/foundations/roving-focus-contract.test.ts
index 0b4a2edc..5a386131 100644
--- a/tests/jsdom/foundations/roving-focus-contract.test.ts
+++ b/tests/jsdom/foundations/roving-focus-contract.test.ts
@@ -60,4 +60,26 @@ describe('rovingFocus contract helpers (FOUNDATIONS)', () => {
expect(navigation.item(0).tabIndex).toBe(0);
expect(navigation.item(1).tabIndex).toBe(-1);
});
+
+ it('should follow computed RTL direction for horizontal arrow navigation', () => {
+ const container = document.createElement('div');
+ container.dir = 'rtl';
+ document.body.append(container);
+ const onNavigate = vi.fn();
+ const navigation = rovingFocus({
+ currentIndex: 0,
+ itemCount: 3,
+ onNavigate,
+ });
+
+ navigation.container.onKeyDown({
+ key: 'ArrowLeft',
+ currentTarget: container,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ });
+
+ expect(onNavigate).toHaveBeenCalledWith(1);
+ container.remove();
+ });
});
diff --git a/tests/jsdom/operations/adversarial-async-generations.test.ts b/tests/jsdom/operations/adversarial-async-generations.test.ts
new file mode 100644
index 00000000..5c872662
--- /dev/null
+++ b/tests/jsdom/operations/adversarial-async-generations.test.ts
@@ -0,0 +1,110 @@
+import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
+import { createMutation, getDefaultDataRuntime } from '../../../src/data';
+import { QueryCell } from '../../../src/data/query-cell';
+import { flushScheduler } from '../../../test-utils/render/test-renderer';
+
+afterEach(() => {
+ vi.useRealTimers();
+ getDefaultDataRuntime().queryData.clear();
+});
+
+describe('adversarial async generations', () => {
+ it('should invalidate successful superseded mutations that ignore cancellation', async () => {
+ let resolveFirst!: (value: string) => void;
+ let resolveSecond!: (value: string) => void;
+ getDefaultDataRuntime().queryData.set('todos:one', { stale: true });
+ const mutation = createMutation({
+ action: (input: string) =>
+ new Promise((resolve) => {
+ if (input === 'first') resolveFirst = resolve;
+ else resolveSecond = resolve;
+ }),
+ affects: () => ['todos:'],
+ afterSuccess: 'invalidate',
+ });
+
+ const first = mutation.execute('first');
+ const second = mutation.execute('second');
+ resolveFirst('first-result');
+ await expect(first).resolves.toBe('first-result');
+
+ expect(getDefaultDataRuntime().queryData.has('todos:one')).toBe(false);
+ expect(mutation.status).toBe('pending');
+
+ resolveSecond('second-result');
+ await expect(second).resolves.toBe('second-result');
+ expect(mutation.result).toBe('second-result');
+ });
+
+ it('should coalesce rapid invalidations before the runtime task starts', async () => {
+ const fetch = vi.fn(async () => ({ id: 'latest' }));
+ const cache = new Map>();
+ const cell = new QueryCell({ key: 'rapid', fetch }, 'rapid', cache);
+ const owner = {};
+ cell.attach(owner, 0);
+ try {
+ cell.refresh();
+ cell.invalidate();
+ cell.invalidate();
+ flushScheduler();
+ await settle();
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(cell.data).toEqual({ id: 'latest' });
+ } finally {
+ cell.detach(owner, 0);
+ }
+ });
+
+ it('should not let a stale reconcile timer restart a newer refresh', 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: 'reconcile-generation',
+ fetch,
+ isConsistent: (value) => value.version > 1,
+ reconcile: () => true,
+ },
+ 'reconcile-generation',
+ cache
+ );
+ const owner = {};
+ cell.attach(owner, 0);
+ try {
+ void cell.refresh();
+ flushScheduler();
+ pending[0]!({ version: 1 });
+ await settle();
+
+ cell.invalidate();
+ flushScheduler();
+ expect(fetch).toHaveBeenCalledTimes(2);
+
+ await vi.advanceTimersByTimeAsync(25);
+ flushScheduler();
+ expect(fetch).toHaveBeenCalledTimes(2);
+
+ pending[1]!({ version: 2 });
+ await settle();
+ expect(cell.data).toEqual({ version: 2 });
+ expect(cell.consistency).toBe('fresh');
+ } 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/performance-optimizations.test.tsx b/tests/jsdom/renderer/performance-optimizations.test.tsx
index 2aaf9deb..0a60d7db 100644
--- a/tests/jsdom/renderer/performance-optimizations.test.tsx
+++ b/tests/jsdom/renderer/performance-optimizations.test.tsx
@@ -155,7 +155,7 @@ describe('performance optimizations (RENDERER)', () => {
expect(refObject.current).toBe(container.firstElementChild);
});
- it('should handle sealed objects gracefully', () => {
+ it('should update an existing writable current property on sealed refs', () => {
const refObject = Object.seal({ current: null });
const Component = () => {
@@ -167,8 +167,7 @@ describe('performance optimizations (RENDERER)', () => {
flushScheduler();
}).not.toThrow();
- // Should not set current on sealed object (silently skip)
- expect(refObject.current).toBe(null);
+ expect(refObject.current).toBe(container.firstElementChild);
});
it('should handle frozen objects gracefully', () => {
diff --git a/tests/jsdom/ssr/deferred-streaming.test.tsx b/tests/jsdom/ssr/deferred-streaming.test.tsx
index 202aec84..fa0b1cc2 100644
--- a/tests/jsdom/ssr/deferred-streaming.test.tsx
+++ b/tests/jsdom/ssr/deferred-streaming.test.tsx
@@ -1,9 +1,17 @@
import { describe, expect, it } from 'vite-plus/test';
import { hydrateSPA } from '../../../src/boot';
-import { createRouteRegistry, route } from '../../../src/router/route';
+import {
+ createRouteRegistry,
+ currentAuth,
+ route,
+} from '../../../src/router/route';
import { defer, Resolve, routeData } from '../../../src/router/deferred';
import { state } from '../../../src/runtime/state';
-import { renderRouteRequest } from '../../../src/ssr';
+import {
+ renderRouteRequest,
+ renderRouteRequestToString,
+} from '../../../src/ssr';
+import type { AuthContext } from '@askrjs/auth';
import {
createTestContainer,
flushScheduler,
@@ -47,6 +55,65 @@ function reactiveDeferredPage() {
}
describe('deferred route streaming', () => {
+ it('should retain request-local auth when a deferred boundary renders after another request', async () => {
+ let release!: (value: string) => void;
+ const pending = new Promise((resolve) => {
+ release = resolve;
+ });
+ const alice: AuthContext = {
+ authenticated: true,
+ principal: { id: 'alice' },
+ session: null,
+ tenant: null,
+ };
+ const bob: AuthContext = {
+ authenticated: true,
+ principal: { id: 'bob' },
+ session: null,
+ tenant: null,
+ };
+ const aliceRegistry = createRouteRegistry(() => {
+ route(
+ '/',
+ () => {
+ const data = routeData();
+ return (
+ pending
}>
+ {() => {currentAuth().principal?.id}
}
+
+ );
+ },
+ { loader: () => ({ message: defer(pending) }) }
+ );
+ });
+ const bobRegistry = createRouteRegistry(() => {
+ route('/', () => {currentAuth().principal?.id}
);
+ });
+
+ const aliceResult = await renderRouteRequest({
+ url: '/',
+ registry: aliceRegistry,
+ authContext: alice,
+ });
+ if (aliceResult.kind !== 'render' || !aliceResult.stream) {
+ throw new Error('expected Alice stream');
+ }
+ const reader = aliceResult.stream.getReader();
+ await reader.read();
+
+ const bobResult = await renderRouteRequestToString({
+ url: '/',
+ registry: bobRegistry,
+ authContext: bob,
+ });
+ expect(bobResult.kind).toBe('render');
+
+ release('ready');
+ const patch = new TextDecoder().decode((await reader.read()).value);
+ expect(patch).toContain('alice
');
+ expect(patch).not.toContain('bob');
+ });
+
it('should flush fallback first and then emit a deterministic fulfilled patch', async () => {
let release!: (value: string) => void;
const pending = new Promise((resolve) => {
diff --git a/tests/jsdom/ssr/hydration.test.tsx b/tests/jsdom/ssr/hydration.test.tsx
index e3c521c5..5c818abe 100644
--- a/tests/jsdom/ssr/hydration.test.tsx
+++ b/tests/jsdom/ssr/hydration.test.tsx
@@ -9,6 +9,8 @@ import {
} from 'vite-plus/test';
import type { JSXElement } from '../../../src/jsx/types';
import { hydrateSPA } from '../../../src/boot';
+import { applySelectiveHydration } from '../../../src/boot/hydration';
+import type { HydrationInteractionReplay } from '../../../src/boot/hydration-interaction-replay';
import { renderToStringSync, renderToString } from '../../../src/ssr';
import { state } from '../../../src/index';
import { createDataRuntime } from '../../../src/data';
@@ -941,6 +943,74 @@ describe('hydration (SSR)', () => {
vi.unstubAllGlobals();
});
+ it.each([
+ { deferUntilIdle: false, skipSelectors: false, deferBelowFold: false },
+ { deferUntilIdle: false, skipSelectors: false, deferBelowFold: true },
+ { deferUntilIdle: false, skipSelectors: true, deferBelowFold: false },
+ { deferUntilIdle: false, skipSelectors: true, deferBelowFold: true },
+ { deferUntilIdle: true, skipSelectors: false, deferBelowFold: false },
+ { deferUntilIdle: true, skipSelectors: false, deferBelowFold: true },
+ { deferUntilIdle: true, skipSelectors: true, deferBelowFold: false },
+ { deferUntilIdle: true, skipSelectors: true, deferBelowFold: true },
+ ])(
+ 'should preserve initial mount timing for hydrate option matrix %#',
+ async ({ deferUntilIdle, skipSelectors, deferBelowFold }) => {
+ vi.useFakeTimers();
+ container.innerHTML = 'static
';
+ const mountOrUpdate = vi.fn();
+ const interactionReplay: HydrationInteractionReplay = {
+ registerDeferredBoundaries: vi.fn(),
+ setOnDeferredBoundariesDrained: vi.fn(),
+ clearDeferredBoundaries: vi.fn(),
+ complete: vi.fn(),
+ abort: vi.fn(),
+ };
+
+ try {
+ const hydration = applySelectiveHydration(
+ container,
+ { handler: () => null, params: {} },
+ '/',
+ undefined,
+ {
+ deferUntilIdle,
+ deferBelowFold,
+ foldThreshold: 100,
+ skipSelectors: skipSelectors ? ['.static'] : undefined,
+ },
+ {
+ registry: routeRegistryFromTable([
+ { path: '/', handler: () => null },
+ ]),
+ },
+ {
+ mountOrUpdate,
+ registerAppNavigation: vi.fn(async () => undefined),
+ registerRootCleanupCallback: vi.fn(() => () => undefined),
+ activateHydrationBoundary: vi.fn(() => true),
+ },
+ interactionReplay
+ );
+
+ const delaysInitialMount = deferUntilIdle && !deferBelowFold;
+ expect(mountOrUpdate).toHaveBeenCalledTimes(
+ delaysInitialMount ? 0 : 1
+ );
+
+ await vi.advanceTimersByTimeAsync(1);
+ await hydration;
+ expect(mountOrUpdate).toHaveBeenCalledTimes(1);
+ expect(
+ container
+ .querySelector('.static')
+ ?.hasAttribute('data-skip-hydrate')
+ ).toBe(skipSelectors);
+ } finally {
+ vi.useRealTimers();
+ }
+ }
+ );
+
it('should defer full hydration until idle when configured', async () => {
let clicks = 0;
@@ -974,6 +1044,38 @@ describe('hydration (SSR)', () => {
expect(clicks).toBe(2);
});
+ it('should preserve idle deferral when permanent skip selectors are also configured', async () => {
+ let clicks = 0;
+ const Component = () => (
+
+
static
+
(clicks += 1)}>
+ idle
+
+
+ );
+ const routes = [{ path: '/', handler: Component }];
+ container.innerHTML = renderToString({
+ url: '/',
+ registry: routeRegistryFromTable(routes),
+ });
+
+ const hydration = hydrateSPA({
+ root: container,
+ registry: routeRegistryFromTable(routes),
+ hydrate: {
+ deferUntilIdle: true,
+ skipSelectors: ['.permanently-static'],
+ },
+ });
+
+ fireEvent.click(container.querySelector('#idle-skip-btn') as HTMLElement);
+ expect(clicks).toBe(0);
+ await hydration;
+ flushScheduler();
+ expect(clicks).toBe(1);
+ });
+
it('should restore the static child slot cache after deferred idle hydration', async () => {
const setStaticChildSlotsCacheEnabledSpy = vi.spyOn(
rendererDom,
diff --git a/tests/jsdom/ssr/route-request-render.test.tsx b/tests/jsdom/ssr/route-request-render.test.tsx
index 2895588c..d87342be 100644
--- a/tests/jsdom/ssr/route-request-render.test.tsx
+++ b/tests/jsdom/ssr/route-request-render.test.tsx
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vite-plus/test';
import { requireUser, type AuthContext } from '@askrjs/auth';
import { createRouteRegistry, route } from '../../../src/router/route';
-import { renderRouteRequestToString } from '../../../src/ssr';
+import { currentAuth } from '../../../src/router/route';
+import { renderRouteRequestToString, renderToString } from '../../../src/ssr';
const user: AuthContext = {
authenticated: true,
@@ -11,6 +12,20 @@ const user: AuthContext = {
};
describe('single-pass route request rendering', () => {
+ it('should expose explicit auth through low-level synchronous route rendering', () => {
+ const registry = createRouteRegistry(() => {
+ route('/account', () => {currentAuth().principal?.id}
);
+ });
+
+ const html = renderToString({
+ url: '/account',
+ registry,
+ authContext: user,
+ });
+
+ expect(html).toContain('user-1
');
+ });
+
it('should render the resolved route without matching it again', async () => {
let requirements = 0;
let renders = 0;