fix: retain uncertain optimistic mutations

This commit is contained in:
DongHyeonka
2026-08-02 02:42:56 +09:00
parent 15645541b7
commit d9afccdd60
15 changed files with 2172 additions and 124 deletions
+40 -9
View File
@@ -11,6 +11,7 @@ export const ASYNC_OVERLAYS = Object.freeze([
"refreshing",
"stale-degraded",
"mutation-pending",
"mutation-effect-unknown",
"mutation-conflict",
] as const);
@@ -19,30 +20,42 @@ export type AsyncOverlay =
refreshing: false;
staleDegraded: false;
mutationPending: false;
mutationEffectUnknown: false;
mutationConflict: false;
}>
| Readonly<{
refreshing: true;
staleDegraded: false;
mutationPending: false;
mutationEffectUnknown: false;
mutationConflict: false;
}>
| Readonly<{
refreshing: false;
staleDegraded: true;
mutationPending: false;
mutationEffectUnknown: false;
mutationConflict: false;
}>
| Readonly<{
refreshing: false;
staleDegraded: false;
mutationPending: true;
mutationEffectUnknown: false;
mutationConflict: false;
}>
| Readonly<{
refreshing: false;
staleDegraded: false;
mutationPending: false;
mutationEffectUnknown: true;
mutationConflict: false;
}>
| Readonly<{
refreshing: false;
staleDegraded: false;
mutationPending: false;
mutationEffectUnknown: false;
mutationConflict: true;
}>;
@@ -54,6 +67,7 @@ export type AsyncSignals = Readonly<{
isStale?: boolean;
isDegraded?: boolean;
isMutationPending?: boolean;
hasMutationEffectUnknown?: boolean;
hasMutationConflict?: boolean;
}>;
@@ -84,15 +98,17 @@ export function deriveAsyncState(signals: AsyncSignals): AsyncState {
: "initial-loading";
const indicator =
signals.hasMutationConflict && hasData
? "mutation-conflict"
: signals.isMutationPending && hasData
? "mutation-pending"
: signals.isStale && signals.isDegraded && hasData
? "stale-degraded"
: signals.isFetching && hasData
? "refreshing"
: null;
signals.hasMutationEffectUnknown && hasData
? "mutation-effect-unknown"
: signals.hasMutationConflict && hasData
? "mutation-conflict"
: signals.isMutationPending && hasData
? "mutation-pending"
: signals.isStale && signals.isDegraded && hasData
? "stale-degraded"
: signals.isFetching && hasData
? "refreshing"
: null;
const overlay = overlayFor(indicator);
return Object.freeze({
@@ -107,6 +123,7 @@ export function deriveAsyncState(signals: AsyncSignals): AsyncState {
export function selectOverlayIndicator(
overlay: AsyncOverlay,
): AsyncState["indicator"] {
if (overlay.mutationEffectUnknown) return "mutation-effect-unknown";
if (overlay.mutationConflict) return "mutation-conflict";
if (overlay.mutationPending) return "mutation-pending";
if (overlay.staleDegraded) return "stale-degraded";
@@ -120,6 +137,7 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay {
refreshing: true,
staleDegraded: false,
mutationPending: false,
mutationEffectUnknown: false,
mutationConflict: false,
});
}
@@ -128,6 +146,7 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay {
refreshing: false,
staleDegraded: true,
mutationPending: false,
mutationEffectUnknown: false,
mutationConflict: false,
});
}
@@ -136,6 +155,16 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay {
refreshing: false,
staleDegraded: false,
mutationPending: true,
mutationEffectUnknown: false,
mutationConflict: false,
});
}
if (indicator === "mutation-effect-unknown") {
return Object.freeze({
refreshing: false,
staleDegraded: false,
mutationPending: false,
mutationEffectUnknown: true,
mutationConflict: false,
});
}
@@ -144,6 +173,7 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay {
refreshing: false,
staleDegraded: false,
mutationPending: false,
mutationEffectUnknown: false,
mutationConflict: true,
});
}
@@ -151,6 +181,7 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay {
refreshing: false,
staleDegraded: false,
mutationPending: false,
mutationEffectUnknown: false,
mutationConflict: false,
});
}
+24 -2
View File
@@ -281,7 +281,8 @@ export function createFailure(
kind: definition.kind,
code: typeof details.code === "string" ? details.code : definition.kind,
retryable:
details.effect === "MAYBE_APPLIED"
details.effect === "MAYBE_APPLIED" ||
details.effect === "APPLIED_CONFIRMED"
? false
: definition.defaultRetryable,
operationId,
@@ -320,7 +321,28 @@ export function createFailure(
action:
details.effect === "MAYBE_APPLIED"
? "contact-support"
: definition.action,
: details.effect === "APPLIED_CONFIRMED"
? "none"
: definition.action,
});
}
/**
* Adds controller-owned mutation effect knowledge without weakening the
* fail-safe handling required for an unknown server-side outcome.
*/
export function withFailureEffect(
failure: AppFailure,
effect: Exclude<FailureEffectCertainty, "NOT_APPLICABLE">,
): AppFailure {
return Object.freeze({
...failure,
effect,
...(effect === "MAYBE_APPLIED"
? { retryable: false as const, action: "contact-support" as const }
: effect === "APPLIED_CONFIRMED"
? { retryable: false as const, action: "none" as const }
: {}),
});
}
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import {
Button,
AsyncSurface,
DirtyNavigationDialog,
ErrorSummary,
Form,
@@ -40,13 +41,23 @@ export default function ReferenceResourceFormPage() {
mapToCommand: toCreateReferenceCommand,
submit,
});
const mutationEffectUnknown =
mutation.state.indicator === "mutation-effect-unknown";
const mutationBlocked =
mutationEffectUnknown || mutation.state.indicator === "mutation-pending";
const guard = useDirtyNavigationGuard(form.dirty && !form.pending);
return (
<Form
id={form.formId}
pending={form.pending}
onSubmit={(event) => void form.submitForm(event)}
onSubmit={(event) => {
if (mutationBlocked) {
event.preventDefault();
return;
}
void form.submitForm(event);
}}
>
<FormPage
breadcrumb={
@@ -95,24 +106,36 @@ export default function ReferenceResourceFormPage() {
<Button
variant="secondary"
onClick={() => navigate("/examples/reference-resources")}
disabled={form.pending}
disabled={form.pending || mutationBlocked}
>
</Button>
<Button type="submit" disabled={form.pending}>
<Button
type="submit"
disabled={form.pending || mutationBlocked}
>
{form.pending ? "저장 중…" : "저장"}
</Button>
<Button
variant="ghost"
onClick={() => form.reset()}
disabled={!form.dirty || form.pending}
disabled={!form.dirty || form.pending || mutationBlocked}
>
</Button>
</FormActions>
}
feedback={
form.result === "success" ? (
mutationEffectUnknown ? (
<AsyncSurface
state={mutation.state}
onReconcileUnknownEffect={(resolution) => {
void mutation.reconcileUnknownEffect(resolution).then(() => {
if (resolution === "APPLIED") form.settleApplied();
});
}}
/>
) : form.result === "success" ? (
<p role="status">.</p>
) : form.result === "conflict" ? (
<p role="status"> .</p>
@@ -3,8 +3,11 @@ import {
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from "react";
import {
hashKey,
type QueryClient,
useMutation,
useQuery,
useQueryClient,
@@ -18,14 +21,20 @@ import type { Result } from "../../../application/result.ts";
import {
createFailure,
normalizeUnknownFailure,
withFailureEffect,
type AppFailure,
} from "../../../contracts/errors.ts";
import type { QueryInvalidationTopic } from "../../../contracts/query-invalidation.ts";
import type {
QueryInvalidationCoordinator,
QueryInvalidationTopic,
} from "../../../contracts/query-invalidation.ts";
import type { MutationIntent } from "../../../contracts/mutation-intent.ts";
import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts";
import {
admitQueryResult,
type BoundMutation,
type BoundQuery,
MUTATION_COORDINATOR_BOUNDS,
type MutationDuplicatePolicy,
} from "../../../contracts/server-state.ts";
import { runtimeIdentityToken } from "../../../contracts/query-keys.ts";
@@ -181,27 +190,75 @@ export function useApplicationQuery<Value>(
});
}
type LegacyMutationOptions<Input, Value> = Readonly<{
type LegacyMutationBase<Input, Value> = Readonly<{
execute(input: Input): Promise<ApplicationResult<Value>>;
duplicatePolicy?: MutationDuplicatePolicy;
invalidate?: readonly QueryInvalidationTopic[];
optimistic?: Readonly<{
queryKey: readonly unknown[];
update(previous: unknown, input: Input): unknown;
}>;
currentData?: unknown;
}>;
type LegacyMutationOptions<Input, Value> = LegacyMutationBase<Input, Value> &
(
| Readonly<{
/** Stable logical identity required to retain an unknown effect across remounts. */
definitionId: string;
optimistic?: never;
}>
| Readonly<{
definitionId?: string;
optimistic: Readonly<{
queryKey: readonly unknown[];
update(previous: unknown, input: Input): unknown;
}>;
}>
);
type ApplicationMutationController<Input, Value> = Readonly<{
state: AsyncState;
submit(input: Input): Promise<ApplicationResult<Value>>;
resolveConflict(): Promise<void>;
reconcileUnknownEffect(
resolution: "APPLIED" | "NOT_APPLIED",
): Promise<void>;
}>;
type MutationExecution<Input> =
| Readonly<{ kind: "BOUND"; input: Input; intent: MutationIntent }>
| Readonly<{ kind: "LEGACY"; input: Input }>;
type MutationAdmission = {
sequence: number;
state: "ACTIVE" | "UNKNOWN" | "RECONCILING" | "SETTLED";
intent: MutationIntent | null;
scope: CacheScopeSnapshot | null;
optimisticLayer: OptimisticLayerLease | null;
optimisticQueryKey: readonly unknown[] | null;
readonly invalidate: readonly QueryInvalidationTopic[];
invalidationCoordinator: QueryInvalidationCoordinator | null;
};
type UnknownEffectChannel = {
readonly key: string;
readonly registry: UnknownEffectRegistry;
readonly owner: QueryClient;
readonly scope: CacheScopeSnapshot | null;
nextSequence: number;
admissions: MutationAdmission[];
version: number;
reconciliationInFlight: boolean;
readonly listeners: Set<() => void>;
disposeScopeListener: (() => void) | null;
};
type UnknownEffectRegistry = {
readonly channels: Map<string, UnknownEffectChannel>;
activeAdmissions: number;
};
const LEGACY_OPTIMISTIC_SCOPE = Object.freeze({
isCurrent: () => true,
});
export function useApplicationMutation<Input, Value>(
options: BoundMutation<Input, Value>,
): ApplicationMutationController<Input, Value>;
@@ -221,7 +278,11 @@ export function useApplicationMutation<Input, Value>(
const optimistic = "optimistic" in options ? options.optimistic : undefined;
const currentData = "currentData" in options ? options.currentData : undefined;
const definitionId =
"definitionId" in options ? options.definitionId : "LEGACY_MUTATION";
"definitionId" in options
? options.definitionId ?? "LEGACY_MUTATION"
: "LEGACY_MUTATION";
const definitionVersion =
"definitionVersion" in options ? options.definitionVersion : 0;
const duplicatePolicy =
"duplicatePolicy" in options ? options.duplicatePolicy : "REJECT_WHILE_ACTIVE";
const [conflict, setConflict] = useState<AppFailure | null>(null);
@@ -232,6 +293,53 @@ export function useApplicationMutation<Input, Value>(
"requiresIdempotencyKey" in options
? options.requiresIdempotencyKey
: false;
const unknownEffectChannelKey = scope
? `bound:${scope.generation}:${scope.fingerprint}:${definitionId}:${definitionVersion}`
: `legacy:${definitionId}:${
optimistic
? hashKey(optimistic.queryKey)
: "non-optimistic"
}`;
const [acquiredUnknownEffectChannel, setAcquiredUnknownEffectChannel] =
useState<UnknownEffectChannel | null>(null);
const unknownEffectChannel =
acquiredUnknownEffectChannel?.owner === queryClient &&
acquiredUnknownEffectChannel.key === unknownEffectChannelKey &&
acquiredUnknownEffectChannel.scope === (scope ?? null)
? acquiredUnknownEffectChannel
: null;
useEffect(() => {
const acquired = acquireUnknownEffectChannel(
queryClient,
unknownEffectChannelKey,
scope,
);
setAcquiredUnknownEffectChannel(acquired);
return () => {
if (acquired) {
releaseUnknownEffectChannelIfUnused(queryClient, acquired);
}
};
}, [queryClient, scope, unknownEffectChannelKey]);
const subscribeToUnknownEffects = useCallback(
(listener: () => void) => {
if (!unknownEffectChannel) return () => {};
unknownEffectChannel.listeners.add(listener);
return () => {
unknownEffectChannel.listeners.delete(listener);
releaseUnknownEffectChannelIfUnused(
queryClient,
unknownEffectChannel,
);
};
},
[queryClient, unknownEffectChannel],
);
useSyncExternalStore(
subscribeToUnknownEffects,
() => unknownEffectChannel?.version ?? 0,
() => unknownEffectChannel?.version ?? 0,
);
const mutation = useMutation<
Value,
ApplicationQueryError,
@@ -246,7 +354,7 @@ export function useApplicationMutation<Input, Value>(
"SCOPE_GENERATION_CHANGED",
definitionId,
0,
{ code: "MUTATION_SCOPE_STALE" },
{ code: "MUTATION_SCOPE_STALE", effect: "NOT_STARTED" },
),
);
}
@@ -298,7 +406,7 @@ export function useApplicationMutation<Input, Value>(
"SCOPE_GENERATION_CHANGED",
definitionId,
0,
{ code: "MUTATION_SCOPE_STALE" },
{ code: "MUTATION_SCOPE_STALE", effect: "NOT_STARTED" },
),
});
}
@@ -311,9 +419,12 @@ export function useApplicationMutation<Input, Value>(
} catch (error) {
return Promise.resolve({
ok: false,
error: normalizeUnknownFailure(error, {
operationId: definitionId,
}),
error: withFailureEffect(
normalizeUnknownFailure(error, {
operationId: definitionId,
}),
"NOT_STARTED",
),
});
}
const active = mutationExecutions(queryClient).get(identity) as
@@ -331,26 +442,58 @@ export function useApplicationMutation<Input, Value>(
"DUPLICATE_IN_FLIGHT",
definitionId,
0,
{ code: "DUPLICATE_IN_FLIGHT" },
{ code: "DUPLICATE_IN_FLIGHT", effect: "NOT_STARTED" },
),
});
}
if (
!unknownEffectChannel ||
unknownEffectChannel.registry.activeAdmissions >=
MUTATION_COORDINATOR_BOUNDS.activeIntentsTotal
) {
identityLease?.release();
return Promise.resolve({
ok: false,
error: createFailure(
"IDENTITY_INTERN_LIMIT_EXCEEDED",
definitionId,
0,
{
code: "UNKNOWN_EFFECT_CHANNEL_LIMIT_EXCEEDED",
effect: "NOT_STARTED",
},
),
});
}
const admission: MutationAdmission = {
sequence: unknownEffectChannel.nextSequence++,
state: "ACTIVE",
intent: null,
scope: scope ?? null,
optimisticLayer: null,
optimisticQueryKey: optimistic?.queryKey ?? null,
invalidate,
invalidationCoordinator: invalidationCoordinator ?? null,
};
unknownEffectChannel.admissions.push(admission);
unknownEffectChannel.registry.activeAdmissions += 1;
notifyUnknownEffectChannel(unknownEffectChannel);
setConflict(null);
mutation.reset();
const pending = (async (): Promise<ApplicationResult<Value>> => {
const execution: MutationExecution<Input> =
scope
? Object.freeze({
kind: "BOUND" as const,
input,
intent: mutationIntentFactory.create({
operationId: mutationOperationId,
canonicalInputIdentity: identity,
requiresIdempotencyKey,
}),
})
: Object.freeze({ kind: "LEGACY" as const, input });
let execution: MutationExecution<Input>;
if (scope) {
const intent = mutationIntentFactory.create({
operationId: mutationOperationId,
canonicalInputIdentity: identity,
requiresIdempotencyKey,
});
admission.intent = intent;
execution = Object.freeze({ kind: "BOUND", input, intent });
} else {
execution = Object.freeze({ kind: "LEGACY", input });
}
const mutationLease =
invalidate.length === 0
? null
@@ -359,54 +502,60 @@ export function useApplicationMutation<Input, Value>(
throw new Error("Query invalidation coordinator is not installed.");
}
try {
let previous: unknown;
let hadPreviousData = false;
let optimisticLayer: OptimisticLayerLease | null = null;
if (optimistic) {
await queryClient.cancelQueries({
queryKey: optimistic.queryKey,
exact: true,
});
if (scope) {
optimisticLayer = optimisticLayers(queryClient).begin(
optimistic.queryKey,
input,
optimistic.update,
scope,
);
} else {
previous = queryClient.getQueryData(optimistic.queryKey);
hadPreviousData = previous !== undefined;
queryClient.setQueryData(
optimistic.queryKey,
optimistic.update(previous, input),
);
}
optimisticLayer = optimisticLayers(queryClient).begin(
optimistic.queryKey,
input,
optimistic.update,
scope ?? LEGACY_OPTIMISTIC_SCOPE,
);
admission.optimisticLayer = optimisticLayer;
}
let value: Value;
try {
value = await mutation.mutateAsync(execution);
} catch (error: unknown) {
if (optimistic) {
if (optimisticLayer) {
optimisticLayer.rollback();
} else if (hadPreviousData) {
queryClient.setQueryData(optimistic.queryKey, previous);
} else {
queryClient.removeQueries({
queryKey: optimistic.queryKey,
exact: true,
});
}
}
const failure =
const reportedFailure =
error instanceof ApplicationQueryError
? error.failure
: normalizeUnknownFailure(error, {
operationId: "APPLICATION_MUTATION",
});
if (failure.kind === "CONFLICT") setConflict(failure);
const failure = mutationFailureWithEffect(reportedFailure);
switch (failure.effect) {
case "NOT_STARTED":
case "NOT_APPLIED":
optimisticLayer?.rollback();
if (failure.kind === "CONFLICT") setConflict(failure);
settleMutationAdmission(unknownEffectChannel, admission);
break;
case "APPLIED_CONFIRMED":
optimisticLayer?.commit();
if (!scope || scope.isCurrent()) {
try {
await invalidationCoordinator?.invalidate(invalidate);
} catch {
// A confirmed command remains committed if refresh fails.
}
}
settleMutationAdmission(unknownEffectChannel, admission);
break;
case "MAYBE_APPLIED":
optimisticLayer?.markUncertain();
if (!scope || scope.isCurrent()) {
admission.state = "UNKNOWN";
notifyUnknownEffectChannel(unknownEffectChannel);
} else {
settleMutationAdmission(unknownEffectChannel, admission);
}
break;
}
return { ok: false, error: failure };
}
@@ -416,6 +565,7 @@ export function useApplicationMutation<Input, Value>(
} catch {
// Cache refresh remains best effort after the server has committed.
}
settleMutationAdmission(unknownEffectChannel, admission);
return { ok: true, value };
} finally {
try {
@@ -426,9 +576,13 @@ export function useApplicationMutation<Input, Value>(
}
})()
.catch((error: unknown) => {
const failure = normalizeUnknownFailure(error, {
operationId: "APPLICATION_MUTATION",
});
const failure = withFailureEffect(
normalizeUnknownFailure(error, {
operationId: "APPLICATION_MUTATION",
}),
"NOT_STARTED",
);
settleMutationAdmission(unknownEffectChannel, admission);
if (failure.kind === "CONFLICT") setConflict(failure);
return { ok: false as const, error: failure };
})
@@ -455,6 +609,7 @@ export function useApplicationMutation<Input, Value>(
mutationOperationId,
requiresIdempotencyKey,
scope,
unknownEffectChannel,
],
);
@@ -467,14 +622,194 @@ export function useApplicationMutation<Input, Value>(
await invalidationCoordinator?.invalidate(invalidate);
}, [invalidate, invalidationCoordinator, mutation]);
const reconcileUnknownEffect = useCallback(
async (resolution: "APPLIED" | "NOT_APPLIED") => {
if (
!unknownEffectChannel ||
unknownEffectChannel.reconciliationInFlight
) {
return;
}
const pending = unknownEffectChannel.admissions[0];
if (!pending || pending.state !== "UNKNOWN") return;
if (pending.scope && !pending.intent) return;
unknownEffectChannel.reconciliationInFlight = true;
pending.state = "RECONCILING";
notifyUnknownEffectChannel(unknownEffectChannel);
try {
pending.optimisticLayer?.reconcile(resolution);
if (
resolution === "APPLIED" &&
(!pending.scope || pending.scope.isCurrent())
) {
try {
await pending.invalidationCoordinator?.invalidate(
pending.invalidate,
);
} catch {
// Explicit reconciliation remains settled if refresh fails.
}
}
} finally {
if (pending.scope && !pending.scope.isCurrent()) {
removeAdmissionOptimisticQuery(queryClient, pending);
}
settleMutationAdmission(unknownEffectChannel, pending);
if (
!unknownEffectChannel.admissions.some(
(admission) => admission.state === "ACTIVE",
)
) {
mutation.reset();
}
// Keep the head intent locked through the current event turn so two
// clicks cannot consume two FIFO records when reconciliation itself
// has no asynchronous invalidation work.
await Promise.resolve();
unknownEffectChannel.reconciliationInFlight = false;
notifyUnknownEffectChannel(unknownEffectChannel);
}
},
[mutation, queryClient, unknownEffectChannel],
);
const unknownEffectHead = unknownEffectChannel?.admissions[0];
return Object.freeze({
state: deriveAsyncState({
data: currentData ?? true,
isMutationPending: mutation.isPending,
isMutationPending:
mutation.isPending ||
unknownEffectHead?.state === "ACTIVE" ||
unknownEffectHead?.state === "RECONCILING",
hasMutationEffectUnknown: unknownEffectHead?.state === "UNKNOWN",
hasMutationConflict: conflict !== null,
}),
submit,
resolveConflict,
reconcileUnknownEffect,
});
}
function mutationFailureWithEffect(failure: AppFailure): AppFailure {
switch (failure.effect) {
case "NOT_STARTED":
case "NOT_APPLIED":
case "APPLIED_CONFIRMED":
case "MAYBE_APPLIED":
return withFailureEffect(failure, failure.effect);
case "NOT_APPLICABLE":
case undefined:
return withFailureEffect(failure, "MAYBE_APPLIED");
}
}
const UNKNOWN_EFFECT_CHANNELS = new WeakMap<
object,
UnknownEffectRegistry
>();
function acquireUnknownEffectChannel(
queryClient: QueryClient,
key: string,
scope: CacheScopeSnapshot | undefined,
): UnknownEffectChannel | null {
let registry = UNKNOWN_EFFECT_CHANNELS.get(queryClient);
if (!registry) {
registry = { channels: new Map(), activeAdmissions: 0 };
UNKNOWN_EFFECT_CHANNELS.set(queryClient, registry);
}
const { channels } = registry;
const existing = channels.get(key);
if (existing) return existing.scope === (scope ?? null) ? existing : null;
if (
channels.size >= MUTATION_COORDINATOR_BOUNDS.activeDefinitionsPerRuntime
) {
return null;
}
const created: UnknownEffectChannel = {
key,
registry,
owner: queryClient,
scope: scope ?? null,
nextSequence: 1,
admissions: [],
version: 0,
reconciliationInFlight: false,
listeners: new Set(),
disposeScopeListener: null,
};
channels.set(key, created);
if (scope) {
const discardScope = () => {
for (const admission of created.admissions) {
removeAdmissionOptimisticQuery(queryClient, admission);
markMutationAdmissionSettled(created, admission);
}
created.admissions = [];
notifyUnknownEffectChannel(created);
releaseUnknownEffectChannelIfUnused(queryClient, created);
};
scope.signal.addEventListener("abort", discardScope, { once: true });
created.disposeScopeListener = () =>
scope.signal.removeEventListener("abort", discardScope);
if (scope.signal.aborted || !scope.isCurrent()) discardScope();
}
return created;
}
function releaseUnknownEffectChannelIfUnused(
queryClient: QueryClient,
channel: UnknownEffectChannel,
): void {
if (channel.admissions.length > 0 || channel.listeners.size > 0) return;
const registry = UNKNOWN_EFFECT_CHANNELS.get(queryClient);
const channels = registry?.channels;
if (channels?.get(channel.key) !== channel) return;
channel.disposeScopeListener?.();
channels.delete(channel.key);
if (channels.size === 0 && registry?.activeAdmissions === 0) {
UNKNOWN_EFFECT_CHANNELS.delete(queryClient);
}
}
function notifyUnknownEffectChannel(channel: UnknownEffectChannel): void {
channel.version += 1;
for (const listener of [...channel.listeners]) listener();
}
function settleMutationAdmission(
channel: UnknownEffectChannel,
admission: MutationAdmission,
): void {
markMutationAdmissionSettled(channel, admission);
while (channel.admissions[0]?.state === "SETTLED") {
channel.admissions.shift();
}
notifyUnknownEffectChannel(channel);
releaseUnknownEffectChannelIfUnused(channel.owner, channel);
}
function markMutationAdmissionSettled(
channel: UnknownEffectChannel,
admission: MutationAdmission,
): void {
if (admission.state === "SETTLED") return;
admission.state = "SETTLED";
channel.registry.activeAdmissions = Math.max(
0,
channel.registry.activeAdmissions - 1,
);
}
function removeAdmissionOptimisticQuery(
queryClient: QueryClient,
admission: MutationAdmission,
): void {
if (!admission.optimisticQueryKey) return;
queryClient.removeQueries({
queryKey: admission.optimisticQueryKey,
exact: true,
});
}
@@ -6,19 +6,25 @@ import { OPTIMISTIC_LAYER_BOUNDS } from "../../../contracts/server-state.ts";
export type OptimisticLayerLease = Readonly<{
commit(): void;
rollback(): void;
markUncertain(): void;
reconcile(resolution: "APPLIED" | "NOT_APPLIED"): void;
}>;
type Layer = {
id: number;
status: "pending" | "committed";
status: "pending" | "uncertain" | "committed";
apply(value: unknown): unknown;
};
type OptimisticLayerScope = Pick<CacheScopeSnapshot, "isCurrent"> &
Partial<Pick<CacheScopeSnapshot, "signal">>;
type EntryState = {
queryKey: readonly unknown[];
scope: CacheScopeSnapshot;
scope: OptimisticLayerScope;
base: unknown;
layers: Layer[];
disposeScopeListener: (() => void) | null;
};
export function createOptimisticLayerRuntime(queryClient: QueryClient) {
@@ -26,6 +32,12 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
let nextId = 1;
let writing = false;
function removeEntry(key: string, entry: EntryState): void {
if (entries.get(key) === entry) entries.delete(key);
entry.disposeScopeListener?.();
entry.disposeScopeListener = null;
}
queryClient.getQueryCache().subscribe((event) => {
if (
writing ||
@@ -42,7 +54,12 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
function project(key: string, entry: EntryState): void {
if (!entry.scope.isCurrent()) {
entries.delete(key);
removeEntry(key, entry);
queryClient.removeQueries({ queryKey: entry.queryKey, exact: true });
return;
}
if (entry.base === undefined && entry.layers.length === 0) {
removeEntry(key, entry);
queryClient.removeQueries({ queryKey: entry.queryKey, exact: true });
return;
}
@@ -50,7 +67,7 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
try {
for (const layer of entry.layers) value = layer.apply(value);
} catch {
entries.delete(key);
removeEntry(key, entry);
return;
}
writing = true;
@@ -68,7 +85,7 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
entry.base = committed.apply(entry.base);
}
project(key, entry);
if (entry.layers.length === 0) entries.delete(key);
if (entry.layers.length === 0) removeEntry(key, entry);
}
return Object.freeze({
@@ -76,23 +93,42 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
queryKey: readonly unknown[],
input: Input,
update: (previous: unknown, input: Input) => unknown,
scope: CacheScopeSnapshot,
scope: OptimisticLayerScope,
): OptimisticLayerLease | null {
if (!scope.isCurrent()) return null;
const current = queryClient.getQueryData(queryKey);
if (current === undefined) return null;
const key = hashKey(queryKey);
let entry = entries.get(key);
if (!entry) {
entry = { queryKey, scope, base: current, layers: [] };
entry = {
queryKey,
scope,
base: current,
layers: [],
disposeScopeListener: null,
};
entries.set(key, entry);
if (scope.signal) {
const selectedEntry = entry;
const discardScope = () => {
removeEntry(key, selectedEntry);
queryClient.removeQueries({ queryKey, exact: true });
};
scope.signal.addEventListener("abort", discardScope, { once: true });
entry.disposeScopeListener = () =>
scope.signal?.removeEventListener("abort", discardScope);
if (scope.signal.aborted || !scope.isCurrent()) {
discardScope();
return null;
}
}
} else if (entry.scope !== scope) {
return null;
}
// §11.5. Overflow falls back to pessimistic execution; an existing
// layer is never silently evicted to make room for a new one.
if (entry.layers.length >= OPTIMISTIC_LAYER_BOUNDS.maxLayersPerQueryKey) {
if (entry.layers.length === 0) entries.delete(key);
if (entry.layers.length === 0) removeEntry(key, entry);
return null;
}
const layer: Layer = {
@@ -103,40 +139,64 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
let projected: unknown;
try {
projected = update(entry.base, input);
} catch {
if (entry.layers.length === 0) entries.delete(key);
return null;
} catch (error) {
if (entry.layers.length === 0) removeEntry(key, entry);
throw error;
}
if (
estimateLayerBytes(projected) >
OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes
) {
if (entry.layers.length === 0) entries.delete(key);
if (entry.layers.length === 0) removeEntry(key, entry);
return null;
}
entry.layers.push(layer);
project(key, entry);
let settled = false;
let state: "pending" | "uncertain" | "settled" = "pending";
const selectedLayer = () => {
if (entries.get(key) !== entry) return undefined;
return entry.layers.find((candidate) => candidate.id === layer.id);
};
return Object.freeze({
commit() {
if (settled) return;
settled = true;
const selected = entry?.layers.find(
(candidate) => candidate.id === layer.id,
);
if (!entry || !selected) return;
if (state !== "pending") return;
state = "settled";
const selected = selectedLayer();
if (!selected) return;
selected.status = "committed";
collapse(key, entry);
},
rollback() {
if (settled) return;
settled = true;
if (!entry) return;
if (state !== "pending") return;
state = "settled";
if (!selectedLayer()) return;
entry.layers = entry.layers.filter(
(candidate) => candidate.id !== layer.id,
);
collapse(key, entry);
},
markUncertain() {
if (state !== "pending") return;
state = "uncertain";
const selected = selectedLayer();
if (!selected) return;
selected.status = "uncertain";
collapse(key, entry);
},
reconcile(resolution) {
if (state !== "uncertain") return;
state = "settled";
const selected = selectedLayer();
if (!selected) return;
if (resolution === "APPLIED") {
selected.status = "committed";
} else {
entry.layers = entry.layers.filter(
(candidate) => candidate.id !== layer.id,
);
}
collapse(key, entry);
},
});
},
});
+26 -5
View File
@@ -87,6 +87,9 @@ export type AsyncSurfaceProps = Readonly<{
onAction?: () => void;
onRetry?: () => void;
onResolveConflict?: () => void;
onReconcileUnknownEffect?: (
resolution: "APPLIED" | "NOT_APPLIED",
) => void;
}>;
export function AsyncSurface({
@@ -95,6 +98,7 @@ export function AsyncSurface({
onAction,
onRetry,
onResolveConflict,
onReconcileUnknownEffect,
}: AsyncSurfaceProps) {
const { message } = useLocale();
if (state.base === "initial-loading") return <LoadingSurface />;
@@ -121,11 +125,13 @@ export function AsyncSurface({
{message(
state.indicator === "stale-degraded"
? "async.staleDegraded"
: state.indicator === "mutation-conflict"
? "async.mutationConflict"
: state.indicator === "mutation-pending"
? "async.mutationPending"
: "async.refreshing",
: state.indicator === "mutation-effect-unknown"
? "async.mutationEffectUnknown"
: state.indicator === "mutation-conflict"
? "async.mutationConflict"
: state.indicator === "mutation-pending"
? "async.mutationPending"
: "async.refreshing",
)}
</span>
{state.indicator === "stale-degraded" && onRetry ? (
@@ -136,6 +142,21 @@ export function AsyncSurface({
{message("action.resolveConflict")}
</Button>
) : null}
{state.indicator === "mutation-effect-unknown" &&
onReconcileUnknownEffect ? (
<>
<Button
onClick={() => onReconcileUnknownEffect("APPLIED")}
>
{message("action.confirmMutationApplied")}
</Button>
<Button
onClick={() => onReconcileUnknownEffect("NOT_APPLIED")}
>
{message("action.confirmMutationNotApplied")}
</Button>
</>
) : null}
</div>
) : null}
{children}
+1
View File
@@ -18,6 +18,7 @@ export type FormResultState =
| "success"
| "validation-error"
| "conflict"
| "effect-unknown"
| "unavailable";
export type MappedValidationFailure<Values extends FormValues> = Readonly<{
+28 -10
View File
@@ -135,6 +135,26 @@ export function useAppForm<
[defaultValues],
);
const settleSuccessfulValues = useCallback(
(settledValues: Values) => {
setFieldErrors({} as FieldErrors<Values>);
setFormErrors([]);
setResult("success");
if (resetOnSuccess) {
setValues(defaultValues);
setInitialValues(defaultValues);
setTouched(new Set());
} else {
setInitialValues(settledValues);
}
},
[defaultValues, resetOnSuccess],
);
const settleApplied = useCallback(() => {
settleSuccessfulValues(values);
}, [settleSuccessfulValues, values]);
const submitForm = useCallback(
async (event?: FormEvent<HTMLFormElement>): Promise<FormResult<Output> | null> => {
event?.preventDefault();
@@ -170,14 +190,7 @@ export function useAppForm<
try {
const outcome = await execution;
if (outcome.ok) {
setResult("success");
if (resetOnSuccess) {
setValues(defaultValues);
setInitialValues(defaultValues);
setTouched(new Set());
} else {
setInitialValues(parsed.data);
}
settleSuccessfulValues(parsed.data);
return outcome;
}
if (outcome.error.kind === "VALIDATION_REJECTED") {
@@ -190,6 +203,11 @@ export function useAppForm<
setFormErrors(mapped.formErrors);
setResult("validation-error");
focusFirstError(mapped.fieldErrors);
} else if (outcome.error.effect === "MAYBE_APPLIED") {
setFormErrors([]);
setResult("effect-unknown");
} else if (outcome.error.effect === "APPLIED_CONFIRMED") {
settleSuccessfulValues(parsed.data);
} else if (outcome.error.kind === "CONFLICT") {
setFormErrors([
message("form.conflict"),
@@ -207,12 +225,11 @@ export function useAppForm<
},
[
allowedServerFields,
defaultValues,
focusFirstError,
mapToCommand,
message,
resetOnSuccess,
schema,
settleSuccessfulValues,
submit,
values,
],
@@ -233,6 +250,7 @@ export function useAppForm<
setValue,
submitForm,
reset,
settleApplied,
});
}
+6
View File
@@ -11,6 +11,8 @@ const PLATFORM_KO_MESSAGES = {
"action.reloadOnce": "한 번 새로고침",
"action.contactSupport": "지원 정보 확인",
"action.resolveConflict": "충돌 해결",
"action.confirmMutationApplied": "변경됨으로 확인",
"action.confirmMutationNotApplied": "변경되지 않음으로 확인",
"action.continueEditing": "계속 작성",
"action.discardAndLeave": "변경 버리고 이동",
"action.signIn": "로그인 시작",
@@ -55,6 +57,7 @@ const PLATFORM_KO_MESSAGES = {
"async.refreshing": "최신 정보를 확인하고 있습니다.",
"async.staleDegraded": "기존 정보를 표시하고 있습니다.",
"async.mutationPending": "변경 사항을 저장하고 있습니다.",
"async.mutationEffectUnknown": "변경 결과를 확인할 수 없습니다.",
"async.mutationConflict": "다른 변경과 충돌했습니다.",
"access.auth.eyebrow": "401 · 인증 필요",
"access.auth.title": "로그인이 필요합니다.",
@@ -163,6 +166,8 @@ const PLATFORM_EN_MESSAGES = {
"action.reloadOnce": "Reload once",
"action.contactSupport": "View support information",
"action.resolveConflict": "Resolve conflict",
"action.confirmMutationApplied": "Confirm the change was applied",
"action.confirmMutationNotApplied": "Confirm the change was not applied",
"action.continueEditing": "Continue editing",
"action.discardAndLeave": "Discard and leave",
"action.signIn": "Start sign-in",
@@ -207,6 +212,7 @@ const PLATFORM_EN_MESSAGES = {
"async.refreshing": "Checking for the latest information.",
"async.staleDegraded": "Showing previously loaded information.",
"async.mutationPending": "Saving changes.",
"async.mutationEffectUnknown": "The result of the change is unknown.",
"async.mutationConflict": "The change conflicts with another update.",
"access.auth.eyebrow": "401 · Authentication required",
"access.auth.title": "Sign-in is required.",