Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions engine/core/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,87 @@ Deno.test("resolve", async (t) => {
});
});

await t.step(
"drops hidden array items (resolver -> undefined) but keeps null",
async () => {
// A hidden array item is a multivariate flag gated by a `never` matcher;
// flag.ts returns `undefined` when no variant matched. Such items must be
// dropped from the array, not left as holes (which serialize to `null`
// and render as empty cards). A resolver returning `null` is a legitimate
// value and is kept.
const resolverMap = {
resolve: (data: unknown) => context.resolve(data),
hiddenFlag: (): unknown => undefined,
nullFlag: (): unknown => null,
keep: (p: { label: string }) => p,
};
const result = await resolve<{ items: unknown[] }>(
{
items: [
{ label: "A", __resolveType: "keep" },
{ __resolveType: "hiddenFlag" },
{ label: "B", __resolveType: "keep" },
{ __resolveType: "nullFlag" },
{ __resolveType: "hiddenFlag" },
],
__resolveType: "resolve",
},
{ ...context, resolvers: resolverMap as unknown as ResolverMap },
);
assertEquals(result, {
items: [{ label: "A" }, { label: "B" }, null],
});
},
);

await t.step(
"an array where every item is hidden becomes empty (not a hole array)",
async () => {
const resolverMap = {
resolve: (data: unknown) => context.resolve(data),
hiddenFlag: (): unknown => undefined,
};
const result = await resolve<{ items: unknown[] }>(
{
items: [
{ __resolveType: "hiddenFlag" },
{ __resolveType: "hiddenFlag" },
],
__resolveType: "resolve",
},
{ ...context, resolvers: resolverMap as unknown as ResolverMap },
);
assertEquals(result, { items: [] });
},
);

await t.step(
"hidden items are dropped at every array depth (nested arrays)",
async () => {
// The fix relies on the filter firing at each array level via recursion,
// and on ordering being preserved when the first element is dropped.
const resolverMap = {
resolve: (data: unknown) => context.resolve(data),
hiddenFlag: (): unknown => undefined,
keep: (p: { label: string }) => p,
};
const result = await resolve<{ groups: unknown[][] }>(
{
groups: [
[
{ __resolveType: "hiddenFlag" },
{ label: "A", __resolveType: "keep" },
],
[{ __resolveType: "hiddenFlag" }],
],
__resolveType: "resolve",
},
{ ...context, resolvers: resolverMap as unknown as ResolverMap },
);
assertEquals(result, { groups: [[{ label: "A" }], []] });
},
);

await t.step("resolves object with no resolvable fields", async () => {
type TestType = {
foo: string;
Expand Down
13 changes: 13 additions & 0 deletions engine/core/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,19 @@ const resolvePropsWithHints = async <
}

if (!type) {
// Compact `undefined` out of resolved arrays. An array element that resolved
// to `undefined` is a "not present" value (the motivating case: a hidden
// item — a multivariate flag gated by a `never` matcher, which flag.ts
// resolves to `undefined` when no variant matched). Object properties that
// resolve to `undefined` already vanish under `JSON.stringify`, but an
// `undefined` array element serializes to a `null` hole that the consuming
// section renders as an empty card — so filter it, making arrays consistent
// with object props. A resolver returning `null` is a legitimate value and
// is kept. `includes` first so the common (nothing-to-drop) case skips the
// reallocation the surrounding hot path deliberately avoids.
if (Array.isArray(mutableProps) && mutableProps.includes(undefined)) {
return mutableProps.filter((item) => item !== undefined) as T;
}
return mutableProps;
}

Expand Down
Loading