@stainless-code/vue-layers
Version:
Vue adapter for @stainless-code/layers — call a layer like an async function and await its response.
732 lines (680 loc) • 20.3 kB
text/typescript
import type {
DataTag,
DefaultLayerError,
ErrorOf,
InferValidatorOutput,
LayerCallContext,
LayerComponentProps,
LayerGroupOptions,
LayerHandle,
LayerKey,
LayerOptions,
LayerStack,
LayerState,
OmitKeyof,
OpenLayerOptions,
ResponseOf,
ValidatedLayerHandle,
Validator,
} from "@stainless-code/layers";
import {
childStackId,
createCallContext,
createLayer,
createLayerGroup,
keySignature,
shallowArrayEqual,
LayerClient,
} from "@stainless-code/layers";
import {
computed,
defineComponent,
Fragment,
h,
inject,
onScopeDispose,
provide,
shallowRef,
watch,
} from "vue";
import type {
Component,
ComputedRef,
InjectionKey,
PropType,
Ref,
SlotsType,
} from "vue";
export * from "@stainless-code/layers";
const LAYER_CLIENT_KEY: InjectionKey<LayerClient> = Symbol("layers.client");
/**
* Provides a {@link LayerClient} to descendant components.
*
* @param client Client to provide. A new client is created when omitted.
* @returns The provided client.
*/
export function provideLayerClient(client?: LayerClient): LayerClient {
const c = client ?? new LayerClient();
provide(LAYER_CLIENT_KEY, c);
return c;
}
/**
* Reads the nearest {@link LayerClient} from Vue injection context.
*
* @returns The nearest provided client.
*/
export function useLayerClient(): LayerClient {
const c = inject(LAYER_CLIENT_KEY, undefined);
if (!c) {
throw new Error(
"[layers/vue] No LayerClient provided — call provideLayerClient() in a parent setup().",
);
}
return c;
}
function defaultSelector(states: LayerState[]): LayerState[] {
return states;
}
type NoValidateOptions<Opts> = Opts extends { validate: Validator<unknown> }
? never
: Opts;
export interface UseStackOptions<T = LayerState[]> {
stack?: string;
select?: (states: LayerState[]) => T;
compare?: (a: T, b: T) => boolean;
}
export interface UseLayerStateOptions<
Key extends LayerKey,
P = unknown,
D = unknown,
U = LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[],
> {
key: Key;
stack?: string;
select?: (states: LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[]) => U;
compare?: (a: U, b: U) => boolean;
}
export type WiredLayerHandle<
P,
R,
E = DefaultLayerError,
D = unknown,
RP = unknown,
> = LayerHandle<P, R, E, D, RP> & {
state: Readonly<Ref<LayerState<P, R, E, D>[]>>;
queued: Readonly<Ref<LayerState<P, R, E, D>[]>>;
top: Readonly<Ref<LayerState<P, R, E, D> | null>>;
};
export type WiredValidatedLayerHandle<
V extends Validator<unknown>,
R,
E = DefaultLayerError,
D = unknown,
RP = unknown,
> = ValidatedLayerHandle<V, R, E, D, RP> & {
state: Readonly<Ref<LayerState<InferValidatorOutput<V>, R, E, D>[]>>;
queued: Readonly<Ref<LayerState<InferValidatorOutput<V>, R, E, D>[]>>;
top: Readonly<Ref<LayerState<InferValidatorOutput<V>, R, E, D> | null>>;
};
/**
* Shared snapshot subscription primitive for mounted and queued stack hooks.
*
* Selector output is memoized against the stable snapshot reference so refs do
* not churn object or array selections.
*/
function useSnapshot<T>(
stack: LayerStack,
getSource: () => LayerState[],
select: (states: LayerState[]) => T,
compare: (a: T, b: T) => boolean,
): Readonly<Ref<T>> {
let cache: { base: LayerState[]; value: T } | null = null;
const runSelect = (base: LayerState[]): T => {
const prev = cache;
if (prev && prev.base === base) return prev.value;
const next = select(base);
if (prev && compare(prev.value, next)) {
cache = { base, value: prev.value };
return prev.value;
}
cache = { base, value: next };
return next;
};
const ref = shallowRef<T>(runSelect(getSource()));
const unsubscribe = stack.subscribe(() => {
const prev = ref.value;
const next = runSelect(getSource());
if (!compare(prev, next)) {
ref.value = next;
}
});
onScopeDispose(unsubscribe);
return ref;
}
/**
* Exposes a {@link LayerClient} stack as a readonly Vue ref.
*
* Call it inside `setup()` or an `effectScope()` so `onScopeDispose` can clean
* up the stack subscription.
*
* @param opts Options bag: `stack`, `select`, `compare`.
* @param client Client to observe. Omit it to use {@link useLayerClient}.
* @returns A readonly ref of the selected stack value.
* @default `stack` is `"default"`; `select` is identity; `compare` is
* `Object.is`.
*/
export function useStack<T = LayerState[]>(
opts: UseStackOptions<T> = {},
client?: LayerClient,
): Readonly<Ref<T>> {
const resolved = client ?? useLayerClient();
const stack = resolved.getStack(opts.stack ?? "default");
const select =
opts.select ?? (defaultSelector as unknown as (states: LayerState[]) => T);
const compare = opts.compare ?? Object.is;
return useSnapshot(stack, () => stack.getSnapshot(), select, compare);
}
/** Exposes a stack's queued snapshot as a readonly Vue ref. */
export function useQueuedStack<T = LayerState[]>(
opts: UseStackOptions<T> = {},
client?: LayerClient,
): Readonly<Ref<T>> {
const resolved = client ?? useLayerClient();
const stack = resolved.getStack(opts.stack ?? "default");
const select =
opts.select ?? (defaultSelector as unknown as (states: LayerState[]) => T);
const compare = opts.compare ?? Object.is;
return useSnapshot(stack, () => stack.getQueuedSnapshot(), select, compare);
}
/**
* Observe all mounted layers matching a key.
*
* A {@link DataTag} key infers its response and error types.
*/
export function useLayerState<
Key extends LayerKey,
P = unknown,
D = unknown,
U = LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[],
>(
opts: UseLayerStateOptions<Key, P, D, U>,
client?: LayerClient,
): Readonly<Ref<U>> {
const sig = keySignature(opts.key);
return useStack<U>(
{
stack: opts.stack,
select: (states) => {
const filtered = states.filter(
(s) => keySignature(s.key) === sig,
) as LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[];
return opts.select ? opts.select(filtered) : (filtered as unknown as U);
},
compare: opts.compare ?? (shallowArrayEqual as (a: U, b: U) => boolean),
},
client,
);
}
/** Observe all queued layers matching a key. */
export function useLayerQueuedState<
Key extends LayerKey,
P = unknown,
D = unknown,
U = LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[],
>(
opts: UseLayerStateOptions<Key, P, D, U>,
client?: LayerClient,
): Readonly<Ref<U>> {
const sig = keySignature(opts.key);
return useQueuedStack<U>(
{
stack: opts.stack,
select: (states) => {
const filtered = states.filter(
(s) => keySignature(s.key) === sig,
) as LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[];
return opts.select ? opts.select(filtered) : (filtered as unknown as U);
},
compare: opts.compare ?? (shallowArrayEqual as (a: U, b: U) => boolean),
},
client,
);
}
function useLayerImpl<P, R, E = DefaultLayerError, D = unknown, RP = unknown>(
options: LayerOptions<P, R, E, D, RP> & { key: LayerKey },
client?: LayerClient,
): WiredLayerHandle<P, R, E, D, RP> & {
top: ComputedRef<LayerState<P, R, E, D> | null>;
} {
const resolved = client ?? useLayerClient();
const stackId = options.stack ?? "default";
const sig = keySignature(options.key);
const selectByKey = (states: LayerState[]) =>
states.filter((s) => keySignature(s.key) === sig) as LayerState<
P,
R,
E,
D
>[];
const state = useStack<LayerState<P, R, E, D>[]>(
{ stack: stackId, select: selectByKey, compare: shallowArrayEqual },
resolved,
);
const queued = useQueuedStack<LayerState<P, R, E, D>[]>(
{ stack: stackId, select: selectByKey, compare: shallowArrayEqual },
resolved,
);
const top = computed(() => state.value.at(-1) ?? null);
const base = createLayer(options, resolved);
return {
...base,
get current() {
return base.current;
},
state,
queued,
top,
};
}
/** Wired handle: `createLayer` + reactive `state`/`queued`/`top`. */
export function useLayer<
V extends Validator<unknown>,
R,
E = DefaultLayerError,
D = unknown,
RP = unknown,
>(
options: LayerOptions<InferValidatorOutput<V>, R, E, D, RP> & {
key: LayerKey;
validate: V;
},
client?: LayerClient,
): WiredValidatedLayerHandle<V, R, E, D, RP>;
export function useLayer<
P,
R,
E = DefaultLayerError,
D = unknown,
RP = unknown,
>(
options: NoValidateOptions<LayerOptions<P, R, E, D, RP> & { key: LayerKey }>,
client?: LayerClient,
): WiredLayerHandle<P, R, E, D, RP>;
export function useLayer<
P,
R,
E = DefaultLayerError,
D = unknown,
RP = unknown,
>(
options: LayerOptions<P, R, E, D, RP> & { key: LayerKey },
client?: LayerClient,
): WiredLayerHandle<P, R, E, D, RP> {
return useLayerImpl(options, client);
}
type AnyComponent = Component<LayerComponentProps<never, never, never, never>>;
/** Render every active layer in a stack with its registered component. */
export const StackOutlet = defineComponent({
name: "StackOutlet",
props: {
stack: { type: String, default: "default" },
rootProps: { type: null, default: undefined },
},
setup(props) {
const client = useLayerClient();
const stk = client.getStack(props.stack);
const states = useStack({ stack: props.stack });
return () =>
h(
Fragment,
states.value.map((s) => {
const layer = stk.getLayer(s.id);
const Component = layer?.component as AnyComponent | undefined;
if (!layer || !Component) {
if (process.env.NODE_ENV !== "production") {
console.warn(
`[layers/vue] No component for layer ${s.id} (key ${JSON.stringify(s.key)}); StackOutlet renders nothing. Provide a \`component\` or use useStack.`,
);
}
return null;
}
const call = createCallContext(stk, layer, s, props.rootProps);
return h(Component, {
key: s.id,
call: call as never,
payload: s.payload as never,
data: s.data as never,
error: s.error as never,
phase: s.phase,
transition: s.transition,
actionStatus: s.actionStatus,
dismissing: s.dismissing,
});
}),
);
},
});
export interface StackSubscribeProps<T = LayerState[]> {
stack?: string;
selector: (states: LayerState[]) => T;
}
/**
* Render a selected stack value through a default scoped slot (cross-adapter
* parity). The slot payload is `{ value: unknown }` — `defineComponent` can't
* thread the selector's return type through a slot; prefer
* `useStack({ stack, select })` in `setup()` for a fully-typed value.
*
* @example
* ```vue
* <StackSubscribe :selector="(s) => s.length">
* <template #default="{ value }">{{ value }} open</template>
* </StackSubscribe>
* ```
*/
export const StackSubscribe = defineComponent({
name: "StackSubscribe",
props: {
stack: { type: String, default: "default" },
selector: {
type: Function as PropType<(states: LayerState[]) => unknown>,
required: true,
},
},
slots: Object as SlotsType<{
default: (payload: { value: unknown }) => unknown;
}>,
setup(props, { slots }) {
const value = useStack({ stack: props.stack, select: props.selector });
return () => slots.default?.({ value: value.value });
},
});
export interface StackHandles {
states: Readonly<Ref<LayerState[]>>;
getCall: (state: LayerState) => LayerCallContext<unknown, unknown>;
}
/** Return the states and call contexts needed to render a stack headlessly. */
export function useStackHandles(
stack = "default",
rootProps?: unknown,
): StackHandles {
const client = useLayerClient();
const stk = client.getStack(stack);
const states = useStack({ stack });
const getCall = (
state: LayerState,
): LayerCallContext<unknown, unknown, unknown> => {
const layer = stk.getLayer(state.id);
return createCallContext(stk, layer!, state, rootProps) as LayerCallContext<
unknown,
unknown,
unknown
>;
};
return { states, getCall };
}
export interface MutationRun<R> {
/** On success, end the layer with `response`; on failure, leave it open and rethrow. */
orEnd: (response: R) => Promise<void>;
}
export interface MutationFlow<R> {
/** True while a `run(...)` async action is in flight. Mirrors the layer's `actionStatus: "running"`. */
pending: Readonly<Ref<boolean>>;
run: (fn: () => Promise<void> | void) => MutationRun<R>;
}
/**
* Coordinate a layer's pending state with an async mutation and end it on success.
*
* @example
* ```vue
* <script setup lang="ts">
* import { type LayerComponentProps, useMutationFlow } from "@stainless-code/vue-layers";
*
* const props = defineProps<LayerComponentProps<void, boolean>>();
* const flow = useMutationFlow(props.call);
* </script>
*
* <template>
* <button
* :disabled="flow.pending.value"
* @click="flow.run(() => Promise.resolve()).orEnd(true)"
* >
* Confirm
* </button>
* </template>
* ```
*/
export function useMutationFlow<P, R, RootProps = unknown>(
call: LayerCallContext<P, R, RootProps>,
): MutationFlow<R> {
const pending = shallowRef(false);
const run = (fn: () => Promise<void> | void): MutationRun<R> => ({
orEnd: async (response: R) => {
pending.value = true;
call.setRunning(true);
try {
await fn();
call.end(response);
} finally {
call.setRunning(false);
pending.value = false;
}
},
});
return { pending, run };
}
/** Open a layer on a pre-bound stack, with {@link DataTag} response and error inference. */
export interface ScopedOpen {
<P, R, E = DefaultLayerError, D = unknown, RootProps = unknown>(
options: OmitKeyof<
OpenLayerOptions<P, R, E, D, RootProps> & {
key: DataTag<LayerKey, R, E>;
},
"stack" | "validate"
>,
): Promise<R>;
<P, R = void, E = DefaultLayerError, D = unknown, RootProps = unknown>(
options: OmitKeyof<OpenLayerOptions<P, R, E, D, RootProps>, "stack">,
): Promise<R>;
}
export interface LayerGroup {
open: ScopedOpen;
dismissAll: (response?: unknown) => void;
states: Readonly<Ref<LayerState[]>>;
/** Renders the child stack inline — place inside the parent layer's DOM. */
Outlet: Component<{ rootProps?: unknown }>;
stackId: string;
}
/**
* Create a child stack scoped to the calling layer's lifetime.
*
* The child stack is disposed and cleared via `cancelAll` when its parent
* layer unmounts (`LayerCancelledError`).
*/
export function useLayerGroup<P, R, RootProps = unknown>(
call: LayerCallContext<P, R, RootProps>,
options?: LayerGroupOptions,
): LayerGroup {
const client = useLayerClient();
const stackId = childStackId(call, options?.name);
const group = createLayerGroup(client, call, options);
onScopeDispose(() => {
group.dispose();
client.cancelAll(group.stackId, { reason: "groupDispose" });
});
const states = useStack({ stack: stackId });
const open = <
P2,
R2 = void,
E = DefaultLayerError,
D = unknown,
RP = unknown,
>(
opts: OmitKeyof<OpenLayerOptions<P2, R2, E, D, RP>, "stack">,
) =>
client.open({
...opts,
stack: stackId,
} as OpenLayerOptions<P2, R2, E, D, RP>);
const dismissAll = (response?: unknown) =>
client.dismissAll(stackId, response);
const Outlet = defineComponent({
name: "LayerGroupOutlet",
props: {
rootProps: { type: null, default: undefined },
},
setup(props) {
return () =>
h(StackOutlet, { stack: stackId, rootProps: props.rootProps });
},
});
return {
open: open as unknown as ScopedOpen,
dismissAll,
states,
Outlet,
stackId,
};
}
export interface AppStack {
open: ScopedOpen;
dismissAll: (response?: unknown) => void;
states: Readonly<Ref<LayerState[]>>;
}
export interface AppLayerProps<P, R = void> {
/** Layer definition with the stack supplied by the factory. */
options: OmitKeyof<LayerOptions<P, R>, "stack">;
/** Controlled visibility. `true` opens the layer; `false` dismisses it. */
open: boolean;
payload: P;
/** Called when the layer resolves. */
onResolved?: (response: R) => void;
}
export interface StackHook<HostProps> {
StackProvider: Component<{ client?: LayerClient }>;
useAppStack: () => AppStack;
/** Host props are forwarded to `config.Host` via fallthrough attrs. */
AppHost: Component<HostProps>;
AppLayer: <P, R = void>(props: AppLayerProps<P, R>) => null;
}
/** Create provider, host, controlled-layer, and access hooks bound to one stack. */
export function createStackHook<HostProps extends object = object>(
config: {
client?: LayerClient;
stack?: string;
Host?: Component<{ default?: unknown } & HostProps>;
} = {},
): StackHook<HostProps> {
const stackId = config.stack ?? "default";
const StackProvider = defineComponent({
name: "StackProvider",
props: {
client: {
type: Object as PropType<LayerClient>,
default: undefined,
},
},
setup(props, { slots }) {
provideLayerClient(props.client ?? config.client);
return () => slots.default?.();
},
});
function useAppStack(): AppStack {
const client = useLayerClient();
const states = useStack({ stack: stackId });
const open = <
P,
R = void,
E = DefaultLayerError,
D = unknown,
RootProps = unknown,
>(
options: OmitKeyof<OpenLayerOptions<P, R, E, D, RootProps>, "stack">,
) =>
client.open({
...options,
stack: stackId,
} as OpenLayerOptions<P, R, E, D, RootProps>);
const dismissAll = (response?: unknown) =>
client.dismissAll(stackId, response);
return { open: open as unknown as ScopedOpen, dismissAll, states };
}
const AppHost = defineComponent({
name: "AppHost",
inheritAttrs: false,
setup(_, { attrs }) {
return () => {
const outlet = h(StackOutlet, { stack: stackId, rootProps: attrs });
return config.Host
? h(config.Host as Component, attrs, { default: () => outlet })
: outlet;
};
},
});
const AppLayerComponent = defineComponent({
name: "AppLayer",
props: {
options: {
type: Object as PropType<
OmitKeyof<LayerOptions<unknown, unknown>, "stack">
>,
required: true,
},
open: { type: Boolean, required: true },
payload: { type: null, default: undefined },
onResolved: {
type: Function as PropType<(response: unknown) => void>,
default: undefined,
},
},
setup(props) {
const client = useLayerClient();
let opened = false;
watch(
() => props.open,
(isOpen) => {
if (isOpen && !opened) {
opened = true;
void client
.open({
...props.options,
stack: stackId,
payload: props.payload,
} as OpenLayerOptions<unknown, unknown>)
.then((response) => {
opened = false;
props.onResolved?.(response);
});
} else if (!isOpen && opened) {
const stack = client.getStack(stackId);
const layer = stack.find(props.options.key);
if (layer) {
stack.dismiss(layer, undefined);
}
opened = false;
}
},
{ immediate: true },
);
onScopeDispose(() => {
if (opened) {
const stack = client.getStack(stackId);
const layer = stack.find(props.options.key);
if (layer) {
stack.dismiss(layer, undefined);
}
opened = false;
}
});
return () => null;
},
});
const AppLayer = AppLayerComponent as unknown as <P, R = void>(
props: AppLayerProps<P, R>,
) => null;
return {
StackProvider,
useAppStack,
AppHost: AppHost as unknown as Component<HostProps>,
AppLayer,
};
}