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"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state';
import { PublicCourseBottomNav } from '@cio/ui/custom/public-course'; import { PublicCourseBottomNav } from '@cio/ui/custom/public-course';
import { courseApi } from '$features/course/api'; import { courseApi } from '$features/course/api';
import { getContentRoute, getCourseProgress } from '$features/course/utils/content'; import { getContentRoute, getCourseProgress } from '$features/course/utils/content';
@@ -28,11 +29,12 @@
let sheetOpen = $state(false); let sheetOpen = $state(false);
const currentPath = $derived(path || page.url.pathname);
const courseProgress = $derived(getCourseProgress(courseApi.course)); const courseProgress = $derived(getCourseProgress(courseApi.course));
const activeIndex = $derived(resolveActiveNavigableContentIndex(courseApi.course, path)); const activeIndex = $derived(resolveActiveNavigableContentIndex(courseApi.course, currentPath));
const activeItem = $derived(getActiveNavigableContent(courseApi.course, path)); const activeItem = $derived(getActiveNavigableContent(courseApi.course, currentPath));
const previousItem = $derived(getPreviousNavigableContent(courseApi.course, path)); const previousItem = $derived(getPreviousNavigableContent(courseApi.course, currentPath));
const nextItem = $derived(getNextIncompleteNavigableContent(courseApi.course, path)); const nextItem = $derived(getNextIncompleteNavigableContent(courseApi.course, currentPath));
const positionLabel = $derived( const positionLabel = $derived(
activeIndex >= 0 && courseProgress.total > 0 ? `${activeIndex + 1} / ${courseProgress.total}` : '' activeIndex >= 0 && courseProgress.total > 0 ? `${activeIndex + 1} / ${courseProgress.total}` : ''
@@ -87,4 +89,4 @@
progressPercent={courseProgress.percent} 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)); 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); const pathSegments = currentPath.split('/').filter(Boolean);
return pathSegments.includes(itemId); 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)); 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); const items = getOrderedNavigableContent(course);
if (items.length === 0) { if (items.length === 0) {
return -1; return -1;
@@ -37,7 +44,10 @@ export function resolveActiveNavigableContentIndex(course: Course | null, curren
return 0; 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 items = getOrderedNavigableContent(course);
const activeIndex = resolveActiveNavigableContentIndex(course, currentPath); const activeIndex = resolveActiveNavigableContentIndex(course, currentPath);
if (activeIndex <= 0) { if (activeIndex <= 0) {
@@ -47,7 +57,10 @@ export function getPreviousNavigableContent(course: Course | null, currentPath:
return items[activeIndex - 1] ?? null; 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 items = getOrderedNavigableContent(course);
const activeIndex = resolveActiveNavigableContentIndex(course, currentPath); const activeIndex = resolveActiveNavigableContentIndex(course, currentPath);
if (activeIndex < 0) { if (activeIndex < 0) {
@@ -64,7 +77,10 @@ export function getNextIncompleteNavigableContent(course: Course | null, current
return null; 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 items = getOrderedNavigableContent(course);
const activeIndex = resolveActiveNavigableContentIndex(course, currentPath); const activeIndex = resolveActiveNavigableContentIndex(course, currentPath);
if (activeIndex < 0) { if (activeIndex < 0) {
@@ -93,7 +109,7 @@ export function getSectionIdForContentItem(course: Course | null, itemId: string
return null; 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); const activeItem = getActiveNavigableContent(course, currentPath);
if (!activeItem) { if (!activeItem) {
return null; return null;
@@ -45,7 +45,6 @@
interface Props { interface Props {
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
path: string;
data: { data: {
course?: Course; course?: Course;
courseId: string; 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 sidebarWidth = $state(COURSE_SIDEBAR_DEFAULT_WIDTH);
let hasLoadedSidebarWidth = $state(false); let hasLoadedSidebarWidth = $state(false);
let sidebarProviderElement = $state<HTMLDivElement | null>(null); let sidebarProviderElement = $state<HTMLDivElement | null>(null);
@@ -208,7 +208,7 @@
style={`--sidebar-width: ${sidebarWidth}px; --side-panel-width: ${sidePanel.width}px;`} style={`--sidebar-width: ${sidebarWidth}px; --side-panel-width: ${sidePanel.width}px;`}
> >
<CourseSidebar <CourseSidebar
{path} path={currentPath}
id={data.courseId} id={data.courseId}
{isCourseReady} {isCourseReady}
{sidebarWidth} {sidebarWidth}
@@ -243,7 +243,7 @@
{/if} {/if}
{#if showMobileBottomNav} {#if showMobileBottomNav}
<CourseMobileBottomNav courseId={data.courseId} {path} /> <CourseMobileBottomNav courseId={data.courseId} path={currentPath} />
{/if} {/if}
{/if} {/if}
</Sidebar.Inset> </Sidebar.Inset>