fix: retain uncertain optimistic mutations
This commit is contained in:
@@ -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
@@ -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);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -18,6 +18,7 @@ export type FormResultState =
|
||||
| "success"
|
||||
| "validation-error"
|
||||
| "conflict"
|
||||
| "effect-unknown"
|
||||
| "unavailable";
|
||||
|
||||
export type MappedValidationFailure<Values extends FormValues> = Readonly<{
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user