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
+ +
+ ); + 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;