fix(course): stop mobile sidebar crash when path is undefined (#934)

The course layout treated `path` as a SvelteKit prop, but parents never
pass it. CourseMobileBottomNav then called isContentItemInPath with
undefined, which crashed on `.split()` and prevented the mobile sidebar
from opening. Use page.url.pathname and guard missing paths.
This commit is contained in:
Rotimi Best
2026-08-19 13:24:20 +01:00
committed by GitHub
parent 55ae6dd4fe
commit 4fae088626
4 changed files with 62 additions and 16 deletions
@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { PublicCourseBottomNav } from '@cio/ui/custom/public-course';
import { courseApi } from '$features/course/api';
import { getContentRoute, getCourseProgress } from '$features/course/utils/content';
@@ -28,11 +29,12 @@
let sheetOpen = $state(false);
const currentPath = $derived(path || page.url.pathname);
const courseProgress = $derived(getCourseProgress(courseApi.course));
const activeIndex = $derived(resolveActiveNavigableContentIndex(courseApi.course, path));
const activeItem = $derived(getActiveNavigableContent(courseApi.course, path));
const previousItem = $derived(getPreviousNavigableContent(courseApi.course, path));
const nextItem = $derived(getNextIncompleteNavigableContent(courseApi.course, path));
const activeIndex = $derived(resolveActiveNavigableContentIndex(courseApi.course, currentPath));
const activeItem = $derived(getActiveNavigableContent(courseApi.course, currentPath));
const previousItem = $derived(getPreviousNavigableContent(courseApi.course, currentPath));
const nextItem = $derived(getNextIncompleteNavigableContent(courseApi.course, currentPath));
const positionLabel = $derived(
activeIndex >= 0 && courseProgress.total > 0 ? `${activeIndex + 1} / ${courseProgress.total}` : ''
@@ -87,4 +89,4 @@
progressPercent={courseProgress.percent}
/>
<CourseMobileOutlineSheet bind:open={sheetOpen} {path} {courseId} />
<CourseMobileOutlineSheet bind:open={sheetOpen} path={currentPath} {courseId} />
@@ -0,0 +1,28 @@
import { isContentItemInPath } from './content-navigation';
describe('isContentItemInPath', () => {
it('returns false when the current path is missing', () => {
expect(isContentItemInPath('lesson-1', undefined)).toBe(false);
expect(isContentItemInPath('lesson-1', null)).toBe(false);
expect(isContentItemInPath('lesson-1', '')).toBe(false);
});
it('matches content ids as exact pathname segments', () => {
expect(isContentItemInPath('lesson-1', '/courses/abc/lessons/lesson-1')).toBe(true);
expect(isContentItemInPath('lesson-1', '/courses/abc/lessons/lesson-10')).toBe(false);
});
});
describe('isContentItemInPath', () => {
it('returns false when the current path is missing', () => {
expect(isContentItemInPath('lesson-1', undefined)).toBe(false);
expect(isContentItemInPath('lesson-1', null)).toBe(false);
expect(isContentItemInPath('lesson-1', '')).toBe(false);
});
it('matches content ids as exact pathname segments', () => {
expect(isContentItemInPath('lesson-1', '/courses/abc/lessons/lesson-1')).toBe(true);
expect(isContentItemInPath('lesson-1', '/courses/abc/lessons/lesson-10')).toBe(false);
});
});
@@ -9,16 +9,23 @@ export function getFirstIncompleteNavigableContent(course: Course | null): Conte
return getOrderedNavigableContent(course).find((item) => !item.isComplete && isNavigableContentUnlocked(item));
}
export function isContentItemInPath(itemId: string, currentPath: string): boolean {
export function isContentItemInPath(itemId: string, currentPath: string | null | undefined): boolean {
if (!itemId || !currentPath) {
return false;
}
const pathSegments = currentPath.split('/').filter(Boolean);
return pathSegments.includes(itemId);
}
export function findActiveNavigableContentIndex(items: ContentItem[], currentPath: string): number {
export function findActiveNavigableContentIndex(items: ContentItem[], currentPath: string | null | undefined): number {
return items.findIndex((item) => isContentItemInPath(item.id, currentPath));
}
export function resolveActiveNavigableContentIndex(course: Course | null, currentPath: string): number {
export function resolveActiveNavigableContentIndex(
course: Course | null,
currentPath: string | null | undefined
): number {
const items = getOrderedNavigableContent(course);
if (items.length === 0) {
return -1;
@@ -37,7 +44,10 @@ export function resolveActiveNavigableContentIndex(course: Course | null, curren
return 0;
}
export function getPreviousNavigableContent(course: Course | null, currentPath: string): ContentItem | null {
export function getPreviousNavigableContent(
course: Course | null,
currentPath: string | null | undefined
): ContentItem | null {
const items = getOrderedNavigableContent(course);
const activeIndex = resolveActiveNavigableContentIndex(course, currentPath);
if (activeIndex <= 0) {
@@ -47,7 +57,10 @@ export function getPreviousNavigableContent(course: Course | null, currentPath:
return items[activeIndex - 1] ?? null;
}
export function getNextIncompleteNavigableContent(course: Course | null, currentPath: string): ContentItem | null {
export function getNextIncompleteNavigableContent(
course: Course | null,
currentPath: string | null | undefined
): ContentItem | null {
const items = getOrderedNavigableContent(course);
const activeIndex = resolveActiveNavigableContentIndex(course, currentPath);
if (activeIndex < 0) {
@@ -64,7 +77,10 @@ export function getNextIncompleteNavigableContent(course: Course | null, current
return null;
}
export function getActiveNavigableContent(course: Course | null, currentPath: string): ContentItem | null {
export function getActiveNavigableContent(
course: Course | null,
currentPath: string | null | undefined
): ContentItem | null {
const items = getOrderedNavigableContent(course);
const activeIndex = resolveActiveNavigableContentIndex(course, currentPath);
if (activeIndex < 0) {
@@ -93,7 +109,7 @@ export function getSectionIdForContentItem(course: Course | null, itemId: string
return null;
}
export function getActiveSectionId(course: Course | null, currentPath: string): string | null {
export function getActiveSectionId(course: Course | null, currentPath: string | null | undefined): string | null {
const activeItem = getActiveNavigableContent(course, currentPath);
if (!activeItem) {
return null;
@@ -45,7 +45,6 @@
interface Props {
children?: import('svelte').Snippet;
path: string;
data: {
course?: Course;
courseId: string;
@@ -53,7 +52,8 @@
};
}
let { children, path, data }: Props = $props();
let { children, data }: Props = $props();
const currentPath = $derived(page.url.pathname);
let sidebarWidth = $state(COURSE_SIDEBAR_DEFAULT_WIDTH);
let hasLoadedSidebarWidth = $state(false);
let sidebarProviderElement = $state<HTMLDivElement | null>(null);
@@ -208,7 +208,7 @@
style={`--sidebar-width: ${sidebarWidth}px; --side-panel-width: ${sidePanel.width}px;`}
>
<CourseSidebar
{path}
path={currentPath}
id={data.courseId}
{isCourseReady}
{sidebarWidth}
@@ -243,7 +243,7 @@
{/if}
{#if showMobileBottomNav}
<CourseMobileBottomNav courseId={data.courseId} {path} />
<CourseMobileBottomNav courseId={data.courseId} path={currentPath} />
{/if}
{/if}
</Sidebar.Inset>