Skip to content

Conversation

@tahminator
Copy link
Owner

@tahminator tahminator commented Jan 14, 2026

635

Description of changes

Checklist before review

  • I have done a thorough self-review of the PR
  • Copilot has reviewed my latest changes, and all comments have been fixed and/or closed.
  • If I have made database changes, I have made sure I followed all the db repo rules listed in the wiki here. (check if no db changes)
  • All tests have passed
  • I have successfully deployed this PR to staging
  • I have done manual QA in both dev (and staging if possible) and attached screenshots below.

Screenshots

Dev

Staging

@github-actions
Copy link
Contributor

Title

635: wip


PR Type

Enhancement, Bug fix


Description

  • Add type-safe React Query key generation

  • Refactor queries to use ApiURL.queryKey

  • Improve leaderboard query invalidations

  • Fix lobby navigation joinCode check


Diagram Walkthrough

flowchart LR
  A["ApiURL enhancements"] -- "generate query keys" --> B["queryKey/_generateKey/prefix"]
  B -- "used by" --> C["Leaderboard queries refactor"]
  B -- "used by" --> D["Submission query refactor"]
  C -- "invalidate via prefix" --> E["React Query cache"]
  F["Lobby navigation"] -- "check joinCode before redirect" --> G["Stable routing"]
Loading

File Walkthrough

Relevant files
Bug fix
useDuelNavigation.ts
Guard lobby navigation by joinCode presence                           

js/src/app/duel/_hooks/useDuelNavigation.ts

  • Guard navigate with lobby.joinCode
  • Update effect deps to include joinCode
+2/-2     
Enhancement
apiURL.ts
Add type-safe React Query key generation to ApiURL             

js/src/lib/api/common/apiURL.ts

  • Add type utilities for path segments
  • Implement _generateKey, queryKey, prefix
  • Store instance _key and expose queryKey getter
  • Enhance typing for query key segments
+146/-0 
index.ts
Refactor leaderboard queries to ApiURL queryKey                   

js/src/lib/api/queries/leaderboard/index.ts

  • Refactor to use ApiURL.create with queryKey
  • Replace manual query keys and fetchers
  • Use ApiURL.prefix for invalidations
  • Inline previous helper functions and remove them
+172/-234
index.ts
Use ApiURL queryKey for submission details query                 

js/src/lib/api/queries/submissions/index.ts

  • Use ApiURL.create to build URL and queryKey
  • Inline fetch logic within useQuery
  • Remove separate fetch helper
+13/-18 

@github-actions
Copy link
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Possible Redirect Loop

The effect now depends on 'data?.payload?.lobby.joinCode' and navigates to '/duel/current' when present, otherwise '/duel'. If either route triggers the same data refetch updating joinCode quickly, this could cause oscillating redirects. Verify navigation stability and guard against repeated navigations.

useEffect(() => {
  if (data?.success && data.payload.lobby.joinCode) {
    navigate("/duel/current");
  } else {
    navigate("/duel");
  }
}, [data?.payload?.lobby.joinCode, data?.success, navigate]);
Query Key Stability

The generated React Query keys include the entire queries and params objects. If object key ordering is not guaranteed, keys might be unstable across renders. Ensure a consistent serialization/order of query keys or sorted entries to avoid unnecessary cache misses.

private static _generateKey<
  const TPath extends PathsKey,
  const TMethod extends HttpMethodUpper,
  const TParams,
  const TQueries,
>(
  path: TPath,
  options: {
    method: TMethod;
    params: TParams;
    queries: TQueries;
  },
): readonly [
  ...PathSegments<TPath>,
  { method: TMethod; params: TParams; queries: TQueries },
] {
  const segments = path.split("/").filter((segment) => segment.length > 0);
  return [
    ...segments,
    {
      method: options.method,
      params: options.params,
      queries: options.queries,
    },
  ] as unknown as readonly [
    ...PathSegments<TPath>,
    { method: TMethod; params: TParams; queries: TQueries },
  ];
}
Invalidation Prefix Consistency

Invalidate calls now use ApiURL.prefix with segments from paths. Confirm that all queries for these resources are created with the same segmented prefix so invalidations hit the intended caches (e.g., 'leaderboard' vs '/api/leaderboard' mismatches).

onSuccess: () => {
  queryClient.invalidateQueries({
    queryKey: ApiURL.prefix("/api/leaderboard"),
  });
  queryClient.invalidateQueries({
    queryKey: ApiURL.prefix("/api/leetcode/submission"),
  });
  queryClient.invalidateQueries({
    queryKey: ApiURL.prefix("/api/leetcode/potd"),
  });

Comment on lines +148 to +159
const segments = path.split("/").filter((segment) => segment.length > 0);
return [
...segments,
{
method: options.method,
params: options.params,
queries: options.queries,
},
] as unknown as readonly [
...PathSegments<TPath>,
{ method: TMethod; params: TParams; queries: TQueries },
];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Avoid as unknown as casts by preserving segment types when splitting. Use a typed helper to narrow non-empty segments so the returned tuple aligns with PathSegments<TPath> at runtime and compile-time. [possible issue, importance: 4]

Suggested change
const segments = path.split("/").filter((segment) => segment.length > 0);
return [
...segments,
{
method: options.method,
params: options.params,
queries: options.queries,
},
] as unknown as readonly [
...PathSegments<TPath>,
{ method: TMethod; params: TParams; queries: TQueries },
];
const isNonEmpty = (s: string): s is Exclude<string, ""> => s.length > 0;
const segments = path.split("/").filter(isNonEmpty);
return [
...segments,
{
method: options.method,
params: options.params,
queries: options.queries,
},
];

Comment on lines +77 to +95
const { url, method, res, queryKey } = ApiURL.create(
"/api/leaderboard/current/user/all",
{
method: "GET",
queries: {
page,
pageSize,
filters,
globalIndex,
query: debouncedQuery,
}),
globalIndex,
...Object.typedFromEntries(
Object.typedEntries(filters).map(([tagEnum, filterEnabled]) => {
const metadata = ApiUtils.getMetadataByTagEnum(tagEnum);

return [metadata.apiKey, filterEnabled];
}),
),
},
},
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Guard against undefined filters to prevent runtime errors when calling Object.typedEntries. Default to an empty object or short-circuit the spread to ensure stability. [possible issue, importance: 3]

Suggested change
const { url, method, res, queryKey } = ApiURL.create(
"/api/leaderboard/current/user/all",
{
method: "GET",
queries: {
page,
pageSize,
filters,
globalIndex,
query: debouncedQuery,
}),
globalIndex,
...Object.typedFromEntries(
Object.typedEntries(filters).map(([tagEnum, filterEnabled]) => {
const metadata = ApiUtils.getMetadataByTagEnum(tagEnum);
return [metadata.apiKey, filterEnabled];
}),
),
},
},
);
const safeFilters = filters ?? {};
const { url, method, res, queryKey } = ApiURL.create(
"/api/leaderboard/current/user/all",
{
method: "GET",
queries: {
page,
pageSize,
query: debouncedQuery,
globalIndex,
...Object.typedFromEntries(
Object.typedEntries(safeFilters).map(([tagEnum, filterEnabled]) => {
const metadata = ApiUtils.getMetadataByTagEnum(tagEnum);
return [metadata.apiKey, filterEnabled];
}),
),
},
},
);

Comment on lines +10 to +15
if (data?.success && data.payload.lobby.joinCode) {
navigate("/duel/current");
} else {
navigate("/duel");
}
}, [data?.success, navigate]);
}, [data?.payload?.lobby.joinCode, data?.success, navigate]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Optional-chain payload and lobby in the condition to match the dependency array and avoid possible undefined access. This prevents runtime errors when data.success is true but payload or lobby is missing. [possible issue, importance: 6]

Suggested change
if (data?.success && data.payload.lobby.joinCode) {
navigate("/duel/current");
} else {
navigate("/duel");
}
}, [data?.success, navigate]);
}, [data?.payload?.lobby.joinCode, data?.success, navigate]);
useEffect(() => {
if (data?.success && data?.payload?.lobby?.joinCode) {
navigate("/duel/current");
} else {
navigate("/duel");
}
}, [data?.payload?.lobby?.joinCode, data?.success, navigate]);

@github-actions
Copy link
Contributor

Overall Project 77.16% 🍏

There is no coverage information present for the Files changed

@tahminator
Copy link
Owner Author

/ai

1 similar comment
@tahminator
Copy link
Owner Author

/ai

@tahminator
Copy link
Owner Author

/review

@tahminator
Copy link
Owner Author

/describe

@tahminator
Copy link
Owner Author

/improve

@github-actions
Copy link
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Navigation Logic

The navigation now depends on the existence of data.payload.lobby.joinCode. If payload or lobby is missing when data.success is true, users may be redirected to /duel unexpectedly. Confirm this matches the acceptance criteria and intended UX for all backend response shapes.

  if (data?.success && data.payload.lobby.joinCode) {
    navigate("/duel/current");
  } else {
    navigate("/duel");
  }
}, [data?.payload?.lobby.joinCode, data?.success, navigate]);
Query Key Stability

The new queryKey arrays include objects for { method, params, queries }. Ensure objects are consistently shaped and deterministic (no undefined vs missing fields, stable key order) so React Query cache/invalidation behaves predictably, especially where queries can be undefined.

export class ApiURL<
  TPathKey extends PathsKey,
  TPathMethod extends PathsMethodKey<TPathKey>,
> {
  private readonly _url: URL;
  private readonly _method: Uppercase<
    Extract<PathsMethodKey<TPathKey>, string>
  >;
  private readonly _key: readonly unknown[];

  private static _generateKey<
    const TPath extends PathsKey,
    const TMethod extends HttpMethodUpper,
    const TParams,
    const TQueries,
  >(
    path: TPath,
    options: {
      method: TMethod;
      params: TParams;
      queries: TQueries;
    },
  ): readonly [
    ...PathSegments<TPath>,
    { method: TMethod; params: TParams; queries: TQueries },
  ] {
    const segments = path.split("/").filter((segment) => segment.length > 0);
    return [
      ...segments,
      {
        method: options.method,
        params: options.params,
        queries: options.queries,
      },
    ] as unknown as readonly [
      ...PathSegments<TPath>,
      { method: TMethod; params: TParams; queries: TQueries },
    ];
  }
Hook Return Parity

Several hooks now inline fetch logic and return useQuery results; verify returned shapes (e.g., data, error, isLoading) and query keys remain compatible with existing consumers, and that removed helpers didn’t include additional response shaping or error normalization relied upon by callers.

    [_setSearchQuery, goTo],
  );

  const toggleGlobalIndex = useCallback(() => {
    setGlobalIndex((prev) => !prev);
    goTo(1);
  }, [setGlobalIndex, goTo]);

  const { url, method, res, queryKey } = ApiURL.create(
    "/api/leaderboard/current/user/all",
    {
      method: "GET",
      queries: {
        page,
        pageSize,
        query: debouncedQuery,
        globalIndex,
        ...Object.typedFromEntries(
          Object.typedEntries(filters).map(([tagEnum, filterEnabled]) => {
            const metadata = ApiUtils.getMetadataByTagEnum(tagEnum);

            return [metadata.apiKey, filterEnabled];
          }),
        ),
      },
    },
  );

  const query = useQuery({
    queryKey,
    queryFn: async () => {
      const response = await fetch(url, {
        method,
      });

      const json = res(await response.json());

      return json;
    },
    placeholderData: keepPreviousData,
  });

  const onFilterReset = useCallback(() => {
    clearFilters();
    goTo(1);

    if (globalIndex) {
      toggleGlobalIndex();
    }
  }, [clearFilters, goTo, globalIndex, toggleGlobalIndex]);

  return {
    ...query,
    page,
    globalIndex,
    goBack,
    goForward,
    goTo,
    searchQuery,
    setSearchQuery,
    filters,
    toggleFilter,
    isAnyFilterEnabled,
    onFilterReset,
    debouncedQuery,
    pageSize,
    toggleGlobalIndex,
  };
};

/**
 * Fetch a list of all leaderboards. This is a super query that
 * also exposes pagination.
 */
export const useAllLeaderboardsMetadataQuery = ({
  initialPage = 1,
  pageSize = 20,
  tieToUrl = true,
}: {
  initialPage?: number;
  pageSize?: number;
  tieToUrl?: boolean;
}) => {
  const { page, goBack, goForward, goTo } = usePagination({
    initialPage: initialPage,
    tieToUrl: tieToUrl,
  });
  /**
   * We wrap _setSearchQuery with a setSearchQuery because we need to run a side effect anytime we update the query.
   */
  const [searchQuery, _setSearchQuery, debouncedQuery] = useURLState(
    "query",
    "",
    {
      enabled: tieToUrl,
      debounce: 500,
    },
  );

  /**
   * Abstracted function so that we can also reset the page back to 1 whenever we update the query.
   * TODO - Move these side effects within the useURLState function, which will make it easier to deal with.
   */
  const setSearchQuery = useCallback(
    (query: string) => {
      _setSearchQuery(query);
      goTo(1);
    },
    [_setSearchQuery, goTo],
  );

  const { url, method, res, queryKey } = ApiURL.create(
    "/api/leaderboard/all/metadata",
    {
      method: "GET",
      queries: {
        page,
        pageSize,
        query: debouncedQuery,
      },
    },
  );

  const query = useQuery({
    queryKey,
    queryFn: async () => {
      const response = await fetch(url, {
        method,
      });

      const json = res(await response.json());

      return json;
    },
    placeholderData: keepPreviousData,

@github-actions
Copy link
Contributor

Title

635: wip


PR Type

Enhancement, Bug fix


Description

  • Add type-safe React Query keys via ApiURL

  • Refactor leaderboard queries to ApiURL.create

  • Expose instance queryKey and static prefix

  • Fix lobby navigation joinCode check


Diagram Walkthrough

flowchart LR
  ApiURL["ApiURL: key generation + prefix"] -- "provides queryKey" --> LeaderboardQueries["Leaderboard hooks useQuery"]
  ApiURL -- "provides queryKey" --> SubmissionQueries["Submission hook useQuery"]
  ApiURL -- "prefix() used for invalidation" --> QueryClient["Invalidate queries"]
  LobbyHook["useLobbyNavigation"] -- "requires lobby.joinCode" --> Navigation["navigate to duel/current"]
Loading

File Walkthrough

Relevant files
Bug fix
useDuelNavigation.ts
Fix lobby navigation by checking joinCode                               

js/src/app/duel/_hooks/useDuelNavigation.ts

  • Add guard for data.payload.lobby.joinCode
  • Update effect deps to include data.payload.lobby.joinCode
+2/-2     
Enhancement
apiURL.ts
Add type-safe React Query key generation to ApiURL             

js/src/lib/api/common/apiURL.ts

  • Add type-level path splitting and segments utilities
  • Implement _generateKey and queryKey generation
  • Add instance queryKey getter and static prefix
  • Store precomputed key on ApiURL instances
+146/-0 
index.ts
Refactor leaderboard hooks to ApiURL queryKey                       

js/src/lib/api/queries/leaderboard/index.ts

  • Refactor to use ApiURL.create and queryKey
  • Replace manual query keys with API-derived keys
  • Add prefix for targeted invalidations
  • Inline previously separate fetch helpers
+172/-234
index.ts
Refactor submission query to ApiURL queryKey                         

js/src/lib/api/queries/submissions/index.ts

  • Use ApiURL.create to build URL and queryKey
  • Inline fetch logic inside useQuery
  • Remove separate fetch helper
+13/-18 

Comment on lines +10 to +15
if (data?.success && data.payload.lobby.joinCode) {
navigate("/duel/current");
} else {
navigate("/duel");
}
}, [data?.success, navigate]);
}, [data?.payload?.lobby.joinCode, data?.success, navigate]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Guard the nested access to data.payload.lobby.joinCode to avoid runtime errors when payload or lobby are undefined. Use optional chaining for all intermediate properties in the condition. [possible issue, importance: 7]

Suggested change
if (data?.success && data.payload.lobby.joinCode) {
navigate("/duel/current");
} else {
navigate("/duel");
}
}, [data?.success, navigate]);
}, [data?.payload?.lobby.joinCode, data?.success, navigate]);
if (data?.success && data?.payload?.lobby?.joinCode) {
navigate("/duel/current");
} else {
navigate("/duel");
}

Comment on lines +77 to +95
const { url, method, res, queryKey } = ApiURL.create(
"/api/leaderboard/current/user/all",
{
method: "GET",
queries: {
page,
pageSize,
filters,
globalIndex,
query: debouncedQuery,
}),
globalIndex,
...Object.typedFromEntries(
Object.typedEntries(filters).map(([tagEnum, filterEnabled]) => {
const metadata = ApiUtils.getMetadataByTagEnum(tagEnum);

return [metadata.apiKey, filterEnabled];
}),
),
},
},
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Avoid including non-serializable values in React Query keys by ensuring filters expansion results in plain primitives only. Also, stabilize the key by not spreading objects with potential booleans/undefined that could vary in shape; normalize undefined to explicit boolean values. [general, importance: 6]

Suggested change
const { url, method, res, queryKey } = ApiURL.create(
"/api/leaderboard/current/user/all",
{
method: "GET",
queries: {
page,
pageSize,
filters,
globalIndex,
query: debouncedQuery,
}),
globalIndex,
...Object.typedFromEntries(
Object.typedEntries(filters).map(([tagEnum, filterEnabled]) => {
const metadata = ApiUtils.getMetadataByTagEnum(tagEnum);
return [metadata.apiKey, filterEnabled];
}),
),
},
},
);
const normalizedFilters = Object.typedFromEntries(
Object.typedEntries(filters).map(([tagEnum, filterEnabled]) => {
const metadata = ApiUtils.getMetadataByTagEnum(tagEnum);
return [metadata.apiKey, Boolean(filterEnabled)];
}),
);
const { url, method, res, queryKey } = ApiURL.create(
"/api/leaderboard/current/user/all",
{
method: "GET",
queries: {
page,
pageSize,
query: debouncedQuery,
globalIndex,
...normalizedFilters,
},
},
);

Comment on lines +387 to +391
static prefix<const TCreatePathKey extends PathsKey>(
prefix: PathsSegments<TCreatePathKey>,
) {
return [...prefix.split("/").filter((s) => s.length > 0)];
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Ensure returned query keys are literal tuples to prevent accidental mutation and to align with React Query expectations. Return a readonly array to avoid downstream modifications. [general, importance: 5]

Suggested change
static prefix<const TCreatePathKey extends PathsKey>(
prefix: PathsSegments<TCreatePathKey>,
) {
return [...prefix.split("/").filter((s) => s.length > 0)];
}
static prefix<const TCreatePathKey extends PathsKey>(
prefix: PathsSegments<TCreatePathKey>,
): readonly string[] {
return prefix.split("/").filter((s) => s.length > 0) as readonly string[];
}

@codecov
Copy link

codecov bot commented Jan 20, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tahminator
Copy link
Owner Author

/deploy

@github-actions
Copy link
Contributor

The command to deploy to staging for the commit 55c0376 has been triggered. View action run

@github-actions
Copy link
Contributor

Staging deployment failed for commit 55c0376

View run

@tahminator
Copy link
Owner Author

/deploy

@github-actions
Copy link
Contributor

The command to deploy to staging for the commit 1f25cac has been triggered. View action run

@github-actions
Copy link
Contributor

Staging deployment failed for commit 1f25cac

View run

@tahminator
Copy link
Owner Author

/deploy

@github-actions
Copy link
Contributor

The command to deploy to staging for the commit 7453a99 has been triggered. View action run

@github-actions
Copy link
Contributor

Staging deployment succeeded for commit 7453a99

View run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants