Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
28c643c
[fix] Activity lists with User Activity model
TechQuery May 3, 2026
6239405
Replace local Award with Award type from @freecodecamp-chengdu/hop-se…
Copilot May 5, 2026
d292915
Address review: explicit target fallback and consistent award variabl…
Copilot May 5, 2026
62be8ee
Replace local Question with hop-service type and add preset fill button
Copilot May 5, 2026
c82a700
Add confirmation guard when filling with defaults overwrites existing…
Copilot May 5, 2026
0dca77b
Fix Award/Question imports: remove re-exports, use import type and st…
Copilot May 5, 2026
ea3060d
Add /user/signIn OAuth page and refactor sessionGuard to redirect whe…
Copilot May 7, 2026
ab2b4a6
Fix: extract shared OAuth scopes constant and URI-encode client_id
Copilot May 7, 2026
ea6e668
Fix signIn page: auth chain only on code param, flatten JSX, function…
Copilot May 7, 2026
c9192b0
Move OAuth URL build to page component, use buildURLData, compose ins…
Copilot May 7, 2026
82aab62
Fix scope separator to comma, remove as any in favor of explicit redi…
Copilot May 7, 2026
39f0061
Add CNB OAuth page, cnbSigner middleware, signInWithCNB, and CNB butt…
Copilot May 8, 2026
9fb830f
Refactor CNB OAuth: validate via CNB API, store in cookie, move logic…
Copilot May 8, 2026
9997ad8
[refactor] simplify Sign In middlewares with multiple OAuth pages
TechQuery May 8, 2026
e3d1590
security: prevent open redirect and CNB token leakage in OAuth pages
Copilot May 9, 2026
4b994e0
[migrate] upgrade to PNPM 11, Lint-Staged 17 & other latest Upstream …
TechQuery May 9, 2026
d3508e0
feat: add CNB workspace creation to GitModel.createOneFrom
Copilot May 9, 2026
3e04487
[fix] Vercel & PNPM compatibility
TechQuery May 10, 2026
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
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@
"noAccumulatingSpread": "warn"
},
"security": {
"noGlobalEval": "error"
"noGlobalEval": "error",
"noDangerouslySetInnerHtml": "warn"
},
"complexity": {
"noCommaOperator": "error"
Expand Down
5 changes: 3 additions & 2 deletions components/Activity/ActivityList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ActivityCard, ActivityCardProps } from './ActivityCard';
export interface ActivityListLayoutProps
extends XScrollListProps<Hackathon>,
Pick<ActivityCardProps, 'onPublish' | 'onDelete'> {
store?: ActivityModel;
type?: ActivityListType;
size?: 'sm' | 'lg';
userId?: number;
Expand All @@ -29,7 +30,7 @@ export const ActivityListLayout: FC<ActivityListLayoutProps> = ({
className="g-4"
xs={1}
sm={2}
{...(size === 'sm' ? {} : !size ? { lg: 3, xxl: 4 } : { lg: 4, xxl: 6 })}
{...(size === 'sm' ? {} : size ? { lg: 4, xxl: 6 } : { lg: 3, xxl: 4 })}
>
{defaultData.map(item => (
<Col key={item.name + item.id}>
Expand All @@ -49,7 +50,7 @@ export const ActivityListLayout: FC<ActivityListLayoutProps> = ({
export type ActivityListProps = ActivityListLayoutProps;

export default class ActivityList extends PureComponent<ActivityListProps> {
store = new ActivityModel();
store = this.props.store || new ActivityModel();

Comment on lines +53 to 54
componentDidMount() {
if (this.props.type === 'admin' && !platformAdmin.isPlatformAdmin)
Expand Down
8 changes: 4 additions & 4 deletions components/Activity/AwardList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import { ObservedComponent } from 'mobx-react-helper';
import { Column, RestTable } from 'mobx-restful-table';
import { Image } from 'react-bootstrap';

import { Award, AwardModel } from '../../models/Activity/Award';
import { Award, AwardModel, AwardTarget } from '../../models/Activity/Award';
import { i18n, I18nContext } from '../../models/Base/Translation';

export const AwardTargetName = ({ t }: typeof i18n) => ({
individual: t('personal'),
team: t('team'),
[AwardTarget.Individual]: t('personal'),
[AwardTarget.Team]: t('team'),
});

@observer
Expand All @@ -27,7 +27,7 @@ export class AwardList extends ObservedComponent<{ store: AwardModel }, typeof i
{
key: 'target',
renderHead: t('type'),
renderBody: ({ target }) => AwardTargetName(i18n)[target],
renderBody: ({ target }) => target && AwardTargetName(i18n)[target],
},
{
key: 'pictures',
Expand Down
2 changes: 1 addition & 1 deletion components/Team/TeamAwardAssignment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const TeamAwardAssignmentLayout: FC<TeamAwardAssignmentLayoutProps> = ({
}) => (
<>
<ol>
{defaultData.map(({ updatedAt, id, description, award: { name } }) => (
{defaultData.map(({ id, award: { name } }) => (
<li key={id} className="list-unstyled">
{name}
</li>
Expand Down
22 changes: 6 additions & 16 deletions models/Activity/Award.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,14 @@
import { User, Base, Media, Team } from '@freecodecamp-chengdu/hop-service';
import {
Award,
AwardAssignment,
AwardTarget,
} from '@freecodecamp-chengdu/hop-service';
import { ListModel, Stream, toggle } from 'mobx-restful';

import { createListStream, InputData, TableModel } from '../Base';
import sessionStore from '../User/Session';

export interface Award extends Record<'hackathonName' | 'name' | 'description', string>, Base {
quantity: number;
target: 'team' | 'individual';
pictures: Media[];
}

export interface AwardAssignment
extends
Omit<Base, 'id'>,
Omit<Award, 'name' | 'quantity' | 'target' | 'pictures'>,
Record<'assignmentId' | 'assigneeId' | 'awardId', number> {
user?: User;
team?: Team;
award: Award;
}
export { Award, AwardAssignment, AwardTarget };

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

  1. 所有的类型都直接引用,不要重复导出
  2. 注意这个包是纯类型包,枚举等运行时不可用,要直接写枚举的具体值,并可能需要断言

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Done in commit 0dca77b:

  • Removed export { Award, AwardAssignment, AwardTarget } from models/Activity/Award.ts — all consumers now import directly from @freecodecamp-chengdu/hop-service
  • Same done for models/Activity/Question.ts
  • All imports from hop-service changed to import type since the package is type-only
  • Runtime enum value AwardTarget.Team replaced with the concrete string 'team' in evaluation.tsx
  • Runtime enum values QuestionType.Text/QuestionType.URL replaced with 'text' as QuestionType / 'url' as QuestionType


export class AwardModel extends TableModel<Award> {
client = sessionStore.client;
Expand Down
22 changes: 20 additions & 2 deletions models/Activity/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import {
Enrollment,
Hackathon,
HackathonFilter,
HackathonStatus,
ListChunk,
Question,
Questionnaire,
} from '@freecodecamp-chengdu/hop-service';
import { action, observable } from 'mobx';
import { persist, restore, toggle } from 'mobx-restful';

import { buildURLData } from 'web-utility';
import { isServer } from '../../configuration';
import { Filter, InputData, TableModel } from '../Base';
import { GitModel } from '../Git';
Expand Down Expand Up @@ -158,10 +160,26 @@ export class ActivityModel extends TableModel<Hackathon, ActivityFilter> {

@toggle('uploading')
async signOne(name: string, form: Enrollment['form'] = []) {
await this.client.put(`${this.baseURI}/${name}/enrollment`, { form });
await this.client.post(`${this.baseURI}/${name}/enrollment`, { form });

return this.currentEnrollment?.getSessionOne();
}
Comment on lines 163 to 167

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify all callers of signOne and whether currentEnrollment is guaranteed to be initialized first.
rg -nP -C3 '\bsignOne\s*\('
rg -nP -C3 '\benrollmentOf\s*\('

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 5175


🏁 Script executed:

# Check the register.tsx file to see if enrollmentOf is called before signOne
head -100 pages/activity/[name]/register.tsx

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 2698


🏁 Script executed:

# Check the full context of the index.tsx file where signOne is called
sed -n '60,130p' pages/activity/[name]/index.tsx

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 1936


🏁 Script executed:

# Check the getSessionOne implementation in EnrollmentModel
rg -nP -A5 'getSessionOne\s*\(' models/

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 1242


🏁 Script executed:

# Check the getOne method in Activity model to see the full initialization flow
sed -n '110,140p' models/Activity/index.ts

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 924


🏁 Script executed:

# Check EnrollmentModel constructor and class definition
sed -n '1,50p' models/Activity/Enrollment.ts

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 1622


🏁 Script executed:

# Check if there's any watch or auto-fetch in the model
rg -nP 'makeAutoObservable|makeObservable|reaction|watch' models/Activity/Enrollment.ts

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 50


The POST enrollment response is discarded and may return undefined in some call paths.

signOne discards the POST response and calls currentEnrollment?.getSessionOne() instead. Two issues:

  1. Silent failure in register.tsx: The page's server-side getOne() is called on a local ActivityModel instance, not the global activityStore. When the client calls signOne(), currentEnrollment is undefined on the global store, so the return silently fails. The enrollmentOf() method only creates a new model without fetching data.

  2. Wasteful redundant fetch: The POST response is discarded and a separate GET request is made via getSessionOne(). This is wasteful and potentially racy depending on backend semantics.

The index.tsx page is safer (calls enrollmentOf() before signOne()), but still depends on getSessionOne() being called elsewhere to populate sessionOne. Confirm the POST enrollment endpoint already returns the new enrollment data, and either return it directly or defer the GET until actually needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@models/Activity/index.ts` around lines 162 - 166, The signOne method
currently discards the POST response and returns
currentEnrollment?.getSessionOne(), which can be undefined when
currentEnrollment is not set (see enrollmentOf usage) and causes a
redundant/racy GET via getSessionOne(); update signOne to capture and return the
POST response body (the newly created enrollment/session data) and also update
the model state (set currentEnrollment or sessionOne on the ActivityModel) from
that response so callers get the authoritative data without an extra GET; ensure
you check the enrollment POST response shape and map it into the model (or
fallback to calling getSessionOne only if the response lacks the expected data).

}

export class UserActivityModel extends ActivityModel {
constructor(
public userId: number,
public role: 'creator' | 'staff' | 'enrollee',
) {
super();
}
Comment on lines +170 to +176

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

UserActivityModel inherits restore(this, 'Activity') — all instances share the same localStorage key as the singleton

ActivityModel line 37 defines restored = !isServer() && restore(this, 'Activity') as a class field initializer. Because UserActivityModel calls super(), that initializer runs for every instance, binding each one to the same 'Activity' localStorage key used by the export default new ActivityModel() singleton (line 185).

Consequences:

  • When UserActivityModel.loadPage stores its paginated result, mobx-restful's restore reaction writes to 'Activity', overwriting the singleton's cache.
  • Any singleton read that later triggers a restore reload (e.g., navigation back to the main activity list) may re-hydrate with the stale user-filtered list.
  • Multiple UserActivityModel instances (enrollee / creator / staff) overwrite each other on the same key.

Override restored in the subclass to opt out of persistence:

🛡️ Proposed fix
 export class UserActivityModel extends ActivityModel {
+  override restored = false as const;
+
   constructor(
     public userId: number,
     public role: 'creator' | 'staff' | 'enrollee',
   ) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@models/Activity/index.ts` around lines 169 - 175, The subclass
UserActivityModel is inheriting ActivityModel's restored field (which runs
restore(this, 'Activity') during super()), causing all instances to share the
singleton's localStorage key; to fix, override the restored field in
UserActivityModel to opt out of persistence by adding a class field like
restored = false (so after super() runs the subclass resets it), ensuring
UserActivityModel instances do not call restore(this, 'Activity') and therefore
don't overwrite the singleton cache—update UserActivityModel (constructor/class
body) to define restored = false and leave ActivityModel.restore logic
unchanged.


async loadPage(pageIndex: number, pageSize: number, filter: HackathonFilter) {
const { body } = await this.client.get<ListChunk<Hackathon>>(
`user/${this.userId}/hackathon/${this.role}?${buildURLData({ ...filter, pageIndex, pageSize })}`,
);
return { pageData: body!.list, totalCount: body!.count };
}
}

export default new ActivityModel();
2 changes: 1 addition & 1 deletion models/User/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export class UserModel extends TableModel<User, UserFilter> {
baseURI = 'user';

async getUserTopList() {
const { body } = await this.client.get<UserRankListChunk>(`activity-log/user-rank`);
const { body } = await this.client.get<UserRankListChunk>('activity-log/user-rank');
return body!.list;
}
}
Expand Down
36 changes: 17 additions & 19 deletions pages/activity/[name]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ import { TeamListLayout } from '../../../components/Team/TeamList';
import { TeamRank } from '../../../components/Team/TeamRank';
import { isServer } from '../../../configuration';
import activityStore, { ActivityModel } from '../../../models/Activity';
import { i18n, I18nContext } from '../../../models/Base/Translation';
import { I18nContext, i18n } from '../../../models/Base/Translation';
import sessionStore from '../../../models/User/Session';
import { convertDatetime } from '../../../utils/time';

const ChinaMap = dynamic(() => import('../../../components/ChinaMap'), { ssr: false });
const ChinaMap = dynamic(() => import('../../../components/ChinaMap'), {
ssr: false,
});

interface ActivityPageProps {
activity: Hackathon;
Expand All @@ -51,7 +53,9 @@ export const getServerSideProps = compose<{ name?: string }, ActivityPageProps>(
activityStore.organizationOf(name).getList(),
]);

return { props: JSON.parse(JSON.stringify({ activity, organizationList })) };
return {
props: JSON.parse(JSON.stringify({ activity, organizationList })),
};
},
);

Expand Down Expand Up @@ -273,14 +277,8 @@ export default class ActivityPage extends ObservedComponent<ActivityPageProps, t
<Row className="mt-3">
<Col lg={9} md={12} sm={12} className="mb-3">
<Tabs defaultActiveKey="detail" id="activity-detail-tabs">
<Tab
as="article"
className="pt-2"
eventKey="detail"
title={t('hackathon_detail')}
dangerouslySetInnerHTML={{ __html: detail }}
>
{/*todo update no data*/}
<Tab className="pt-2" eventKey="detail" title={t('hackathon_detail')}>
<article dangerouslySetInnerHTML={{ __html: detail }} />
</Tab>
Comment on lines +280 to 282

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Sanitize detail HTML before rendering to prevent XSS.

detail is injected verbatim into the DOM via dangerouslySetInnerHTML. If this field is admin- or user-authored (rich-text editor output from the API), any embedded <script>, inline event handlers, or javascript: URIs will execute in the viewer's browser. The static analysis tool (CWE-79) flagged this as an unsanitized injection.

Additionally, detail may be undefined for hackathons that haven't populated this field, which should be guarded against.

The recommended fix is to sanitize on the client with DOMPurify:

🛡️ Proposed fix

Install DOMPurify:

pnpm add dompurify
pnpm add -D `@types/dompurify`

Apply at the callsite:

+ import DOMPurify from 'dompurify';

- <article dangerouslySetInnerHTML={{ __html: detail }} />
+ <article dangerouslySetInnerHTML={{ __html: isServer() ? '' : DOMPurify.sanitize(detail ?? '') }} />

Note: DOMPurify requires a DOM environment, so you must guard with isServer() (already imported) or use the SSR build (dompurify/dist/purify.cjs with a JSDOM window shim). Alternatively, sanitize the HTML on the server before returning it from getServerSideProps using sanitize-html or DOMPurify with JSDOM.

🧰 Tools
🪛 ast-grep (0.42.1)

[warning] 280-280: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/activity/`[name]/index.tsx around lines 280 - 282, The Tab rendering
currently injects unsanitized HTML via dangerouslySetInnerHTML using the
variable detail; sanitize detail before rendering and guard against undefined.
Install and import DOMPurify (or use server-side sanitize in
getServerSideProps), then when rendering in the component that contains <Tab
...> and <article dangerouslySetInnerHTML={{ __html: detail }} />, replace
detail with a sanitized value (e.g., sanitizedDetail = DOMPurify.sanitize(detail
|| '') ), ensuring DOMPurify is only invoked in the browser (use the existing
isServer() guard) or perform sanitization inside getServerSideProps and pass the
safe string down to the component.

<Tab className="pt-2" eventKey="log" title={t('latest_news')}>
<AnnouncementList store={myMessage} />
Expand All @@ -304,15 +302,15 @@ export default class ActivityPage extends ObservedComponent<ActivityPageProps, t
renderList={allItems => <TeamListLayout defaultData={allItems} />}
/>
</Tab>
</Tabs>
<Tab eventKey="award" title={t('award')} className="pt-2">
<AwardList store={this.awardStore} />
</Tab>
{ActivityModel.isEvaluatable(activity) && (
<Tab eventKey="team-rank" title={t('works_awards')} className="pt-2">
<TeamRank activityName={name} teamStore={this.teamStore} />
<Tab eventKey="award" title={t('award')} className="pt-2">
<AwardList store={this.awardStore} />
</Tab>
)}
{ActivityModel.isEvaluatable(activity) && (
<Tab eventKey="team-rank" title={t('works_awards')} className="pt-2">
<TeamRank activityName={name} teamStore={this.teamStore} />
</Tab>
)}
</Tabs>
</Col>
<Col className="d-flex flex-column">
{organizationList.length > 0 && (
Expand Down
16 changes: 7 additions & 9 deletions pages/activity/[name]/manage/evaluation.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { observer } from 'mobx-react';
import { ObservedComponent } from 'mobx-react-helper';
import { NewData } from 'mobx-restful';
import { compose, RouteProps, router } from 'next-ssr-middleware';
import { FC, FormEvent, useContext } from 'react';
import { Button, Col, Form, InputGroup, Row } from 'react-bootstrap';
Expand All @@ -9,7 +8,7 @@ import { formToJSON } from 'web-utility';
import { ActivityManageFrame } from '../../../../components/Activity/ActivityManageFrame';
import { TeamAwardList } from '../../../../components/Team/TeamAwardList';
import activityStore from '../../../../models/Activity';
import { AwardAssignment } from '../../../../models/Activity/Award';
import { AwardTarget } from '../../../../models/Activity/Award';
import { i18n, I18nContext } from '../../../../models/Base/Translation';
import { sessionGuard } from '../../../api/core';

Expand Down Expand Up @@ -53,14 +52,13 @@ class EvalationEditor extends ObservedComponent<EvaluationPageProps, typeof i18n

const { awardStore, store } = this,
form = event.currentTarget,
data = formToJSON<NewData<AwardAssignment>>(form);
{ award: awardId } = formToJSON<{ award: number }>(form);

await awardStore.getOne(data.awardId!);
await awardStore.getOne(awardId);

const assignmentStore = awardStore.assignmentOf(data.awardId!),
assigneeId = store.currentOne.id;
const assignmentStore = awardStore.assignmentOf(awardId);

await assignmentStore.updateOne({ assigneeId });
await assignmentStore.updateOne({ team: store.currentOne });
await store.refreshList();

store.clearCurrent();
Expand Down Expand Up @@ -93,14 +91,14 @@ class EvalationEditor extends ObservedComponent<EvaluationPageProps, typeof i18n
<ul className="list-unstyled">
{allItems.map(
({ id, name, quantity, target }) =>
target === 'team' && (
target === AwardTarget.Team && (
<li key={id} className="d-flex mx-2 my-3">
{awardTeamId ? (
<Form.Check
key={id}
type="radio"
label={name}
name="awardId"
name="award"
value={id}
required
/>
Expand Down
4 changes: 2 additions & 2 deletions pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Badge, Button, Card, Carousel, Col, Container, Image, Row } from 'react
import { ActivityListLayout } from '../components/Activity/ActivityList';
import { PageHead } from '../components/layout/PageHead';
import { ActivityModel } from '../models/Activity';
import { i18n, I18nContext } from '../models/Base/Translation';
import { I18nContext, i18n } from '../models/Base/Translation';
import { UserModel } from '../models/User';
import styles from './index.module.less';

Expand Down Expand Up @@ -143,7 +143,7 @@ export default class HomePage extends ObservedComponent<HomePageProps, typeof i1
{this.renderPrimaryActions()}
</nav>

<ul className="d-flex flex-wrap gap-4 mt-5">
<ul className="list-unstyled d-flex flex-wrap gap-4 mt-5">
{this.stats.map(({ label, value }) => (
<li key={label}>
<div className="display-6 fw-semibold">{value}</div>
Expand Down
10 changes: 6 additions & 4 deletions pages/user/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import { FC, useContext, useEffect, useState } from 'react';
import { Badge, Button, Card, Col, Container, Image, Nav, Row, Tab } from 'react-bootstrap';

import { PageHead } from '../../components/layout/PageHead';
import { UserActivityModel } from '../../models/Activity';
import { I18nContext } from '../../models/Base/Translation';
import sessionStore from '../../models/User/Session';
import userStore from '../../models/User';
import sessionStore from '../../models/User/Session';
import styles from './[id].module.less';

const ActivityList = dynamic(() => import('../../components/Activity/ActivityList'), {
Expand Down Expand Up @@ -40,6 +41,7 @@ const UserDetailPage: FC<PublicUser> = observer(({ id, name, avatar }) => {
const { user } = sessionStore;
const [isOwner, setIsOwner] = useState(false);

// biome-ignore lint/correctness/useExhaustiveDependencies: user?.id is redundant when id is already listed
useEffect(() => {
setIsOwner(user?.id === id);
}, [id, user?.id]);
Expand Down Expand Up @@ -154,13 +156,13 @@ const UserDetailPage: FC<PublicUser> = observer(({ id, name, avatar }) => {

<Tab.Content>
<Tab.Pane eventKey="enrolled">
<ActivityList type="enrolled" userId={id} />
<ActivityList store={new UserActivityModel(id, 'enrollee')} />
</Tab.Pane>
<Tab.Pane eventKey="created">
<ActivityList type="created" userId={id} />
<ActivityList store={new UserActivityModel(id, 'creator')} />
</Tab.Pane>
<Tab.Pane eventKey="admin">
<ActivityList type="admin" userId={id} />
<ActivityList store={new UserActivityModel(id, 'staff')} />
Comment on lines 161 to +165
</Tab.Pane>
Comment on lines 157 to 166

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the type of User.id from the hop-service package
echo "=== User interface from hop-service ==="
fd -i "index.d.ts" node_modules/@freecodecamp-chengdu/hop-service --exec grep -n "id" {}

echo ""
echo "=== User type in codebase ==="
rg -n --type ts "interface User|type User" -C 5

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 804


🏁 Script executed:

# Find the pages directory and the specific file
fd "user.*\[id\]" pages/
# Also look for pages/user directory
ls -la pages/user/ 2>/dev/null || echo "pages/user not found"
# Check if it's TypeScript or JavaScript
fd "\[id\]" pages/

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 408


🏁 Script executed:

# Find UserActivityModel definition
rg -n "class UserActivityModel|export.*UserActivityModel" --type ts

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 152


🏁 Script executed:

# Find ActivityList component and check if it's PureComponent
rg -n "class ActivityList|const ActivityList|export.*ActivityList" --type ts --type tsx

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 96


🏁 Script executed:

# Check the User type definition in codebase
rg -n "^export.*interface User|^export.*type User" --type ts

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 142


🏁 Script executed:

# Get the full pages/user/[id].tsx file or at least lines around 157-166
fd -e tsx -e ts "user" pages/ | head -10

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 50


🏁 Script executed:

# Read pages/user/[id].tsx to verify the problematic code
cat -n pages/user/[id].tsx

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 7780


🏁 Script executed:

# Find ActivityList definition
rg -n "ActivityList" --type ts -A 3

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 5468


🏁 Script executed:

# Find UserActivityModel definition and constructor
cat -n models/Activity/index.ts | head -200

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 6694


🏁 Script executed:

# Check for PureComponent in ActivityList
rg -n "PureComponent|extends.*Component" models/Activity/ --type ts

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 50


🏁 Script executed:

# Check User import and type
rg -n "import.*User.*from.*hop-service" --type ts

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 904


new UserActivityModel(...) inline in JSX creates new instances on every re-render

UserDetailPage is wrapped with observer(), so it re-renders whenever any MobX observable it reads changes (e.g., sessionStore.user loading after login). Each render creates three new UserActivityModel instances. Because each instance inherits restore(this, 'Activity'), every re-render triggers three redundant localStorage operations.

Additionally, ActivityList (a PureComponent) stores the store prop in a class field at construction time, so any ActivityList that has already mounted will silently ignore replacement instances passed on subsequent renders.

Memoize the models keyed on id:

🛠️ Proposed fix
-import { FC, useContext, useEffect, useState } from 'react';
+import { FC, useMemo, useContext, useEffect, useState } from 'react';

 const UserDetailPage: FC<PublicUser> = observer(({ id, name, avatar }) => {
   const { t } = useContext(I18nContext);
   const { user } = sessionStore;
   const [isOwner, setIsOwner] = useState(false);
+
+  const enrolleeStore = useMemo(() => new UserActivityModel(id, 'enrollee'), [id]);
+  const creatorStore = useMemo(() => new UserActivityModel(id, 'creator'), [id]);
+  const staffStore = useMemo(() => new UserActivityModel(id, 'staff'), [id]);
 
   // ...
 
                     <Tab.Pane eventKey="enrolled">
-                      <ActivityList store={new UserActivityModel(id, 'enrollee')} />
+                      <ActivityList store={enrolleeStore} />
                     </Tab.Pane>
                     <Tab.Pane eventKey="created">
-                      <ActivityList store={new UserActivityModel(id, 'creator')} />
+                      <ActivityList store={creatorStore} />
                     </Tab.Pane>
                     <Tab.Pane eventKey="admin">
-                      <ActivityList store={new UserActivityModel(id, 'staff')} />
+                      <ActivityList store={staffStore} />
                     </Tab.Pane>

Also verify that User.id (the type of id in PublicUser) matches the userId: number constructor parameter of UserActivityModel.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Tab.Content>
<Tab.Pane eventKey="enrolled">
<ActivityList type="enrolled" userId={id} />
<ActivityList store={new UserActivityModel(id, 'enrollee')} />
</Tab.Pane>
<Tab.Pane eventKey="created">
<ActivityList type="created" userId={id} />
<ActivityList store={new UserActivityModel(id, 'creator')} />
</Tab.Pane>
<Tab.Pane eventKey="admin">
<ActivityList type="admin" userId={id} />
<ActivityList store={new UserActivityModel(id, 'staff')} />
</Tab.Pane>
<Tab.Content>
<Tab.Pane eventKey="enrolled">
<ActivityList store={enrolleeStore} />
</Tab.Pane>
<Tab.Pane eventKey="created">
<ActivityList store={creatorStore} />
</Tab.Pane>
<Tab.Pane eventKey="admin">
<ActivityList store={staffStore} />
</Tab.Pane>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/user/`[id].tsx around lines 157 - 166, UserDetailPage currently
constructs three new UserActivityModel instances inline in JSX (new
UserActivityModel(id, 'enrollee') / 'creator' / 'staff'), causing recreation on
every observer-triggered re-render (and redundant restore(...) calls) and also
being ignored by mounted ActivityList components; change to memoize or reuse one
model instance per (id, role) — e.g., create/get models from a stable cache or
use React.useMemo keyed on id and role and pass those cached instances into
ActivityList instead of constructing inline; update references around
UserDetailPage, UserActivityModel, and ActivityList (and remove inline new ...
in Tab.Pane) and ensure the PublicUser/User.id type matches the
UserActivityModel constructor parameter userId:number.

</Tab.Content>
</Tab.Container>
Expand Down