Skip to content

Refactor SICP/SICPy layout hooks and add caching - #4092

Merged
RichDom2185 merged 15 commits into
masterfrom
refactor-sicp-layout-hooks
Jul 14, 2026
Merged

RichDom2185 merged 15 commits into
masterfrom
refactor-sicp-layout-hooks

Conversation

@RichDom2185

Copy link
Copy Markdown
Member

Description

Reduces duplication, improves abstraction, adds caching (so that each page is only fetched once, going back and forth between pages won’t refresh the content each time, resulting in instant loads).

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update
  • Code quality improvements

How to test

Persistence (local storage) should still work. Page is cached. Index page (special handling) still works

Checklist

  • I have tested this code
  • I have updated the documentation

@RichDom2185 RichDom2185 self-assigned this Jul 14, 2026
@coderabbitai

coderabbitai Bot commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The change moves local-storage state helpers into a dedicated hook, adds SICP React Query keys and hooks, and migrates the JavaScript and Python SICP layouts to query-based fetching, persistence, parsing, loading, error handling, and scrolling.

Changes

SICP query migration

Layer / File(s) Summary
Storage and shared helpers
src/commons/hooks/useLocalStorageState.ts, src/commons/utils/Hooks.ts, src/commons/utils/LocalStorageHelper.ts, src/features/sicp/utils/SicpUtils.ts, src/new_routes/_layout.tsx
Local-storage helpers move to a dedicated hook module, the previous hook and helper exports are removed, and shared scrolling plus updated imports are introduced.
SICP query and persistence path
src/queryKeys/sicp.ts, src/queryKeys/index.ts, src/pages/sicp/hooks/useSicpQuery.ts
SICP query factories and React Query hooks fetch JavaScript and Python sections, parse them with refs, map parse failures, and persist the active section.
SICP JavaScript layout integration
src/new_routes/sicpjs/_layout.tsx
The JavaScript layout adopts query-provided data, loading state, errors, refs, redirects, and hash scrolling.
SICP Python layout integration
src/new_routes/sicpy/_layout.tsx
The Python layout adopts query-provided data, loading state, errors, refs, redirects, scrolling, and outlet context.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main refactor and caching change in the SICP/SICPy layouts.
Description check ✅ Passed The description covers summary, change type, testing, and checklist, with only minor missing details like issue reference and dependencies.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the SICP JS and Python section fetching to use React Query instead of manual fetch calls within useEffect hooks, centralizing query keys and state persistence. The review feedback highlights several critical issues: a bug in section persistence that prematurely resets the cached section to the default index during initial loading, a stale closure issue where globally cached JSX elements close over outdated refs (which can be resolved by moving the parsing logic to a local useMemo hook), and a performance concern regarding synchronous localStorage reads on every render that should be addressed with a lazy state initializer.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@coveralls

coveralls commented Jul 14, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 29308226642

Warning

No base build found for commit 4980a1f on master.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 42.244%

Details

  • Patch coverage: 2 uncovered changes across 1 file (12 of 14 lines covered, 85.71%).

Uncovered Changes

File Changed Covered %
src/commons/hooks/useLocalStorageState.ts 14 12 85.71%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 14187
Covered Lines: 6442
Line Coverage: 45.41%
Relevant Branches: 7544
Covered Branches: 2738
Branch Coverage: 36.29%
Branches in Coverage %: Yes
Coverage Strength: 28.05 hits per line

💛 - Coveralls

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (5)
src/commons/hooks/useLocalStorageState.ts (1)

32-32: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Use a lazy initializer for useState to avoid synchronous storage reads on every render.

readLocalStorage performs synchronous I/O and JSON parsing. Passing it directly to useState executes this logic on every single render, even though React discards the result after the initial mount. Wrap it in a function to evaluate it only once.

♻️ Proposed refactor
-  const [value, setValue] = useState<T>(readLocalStorage(key, defaultValue));
+  const [value, setValue] = useState<T>(() => readLocalStorage(key, defaultValue));
🤖 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 `@src/commons/hooks/useLocalStorageState.ts` at line 32, Update the useState
initialization in useLocalStorageState to pass a lazy initializer function that
calls readLocalStorage(key, defaultValue), ensuring the storage read and parsing
occur only during initial state creation.
src/queryKeys/sicp.ts (1)

8-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

sectionJs/sectionPy are near-identical; consider a shared fetch helper.

Both definitions differ only in base URL. A small fetchSicpSection(baseUrl, section, signal) helper would remove the duplication (and fix the res.json() vs await res.json() inconsistency in the same place).

♻️ Proposed refactor
+async function fetchSicpSection(baseUrl: string, section: string, signal: AbortSignal) {
+  const res = await fetch(baseUrl + encodeURIComponent(section) + extension, { signal });
+  if (!res.ok) {
+    throw new Error(res.statusText);
+  }
+  return res.json();
+}
+
 export const sicp = createQueryKeys('sicp', {
   sectionJs: (section: string) => ({
     queryKey: ['js', section],
-    queryFn: async ({ signal }) => {
-      const res = await fetch(baseUrlJs + section + extension, { signal });
-      if (!res.ok) {
-        throw new Error(res.statusText);
-      }
-      return res.json();
-    },
+    queryFn: ({ signal }) => fetchSicpSection(baseUrlJs, section, signal),
   }),
   sectionPy: (section: string) => ({
     queryKey: ['py', section],
-    queryFn: async ({ signal }) => {
-      const res = await fetch(baseUrlPy + section + extension, { signal });
-      if (!res.ok) {
-        throw new Error(res.statusText);
-      }
-      return await res.json();
-    },
+    queryFn: ({ signal }) => fetchSicpSection(baseUrlPy, section, signal),
   }),
 });
🤖 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 `@src/queryKeys/sicp.ts` around lines 8 - 29, Extract the duplicated request
logic from sicp.sectionJs and sicp.sectionPy into a shared fetchSicpSection
helper accepting baseUrl, section, and signal. Have both query functions
delegate to it, preserving the existing response validation while standardizing
JSON parsing through the helper.
src/new_routes/sicpjs/_layout.tsx (2)

74-83: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated hash-lookup logic between the JS and Python layouts has drifted. Both scroll effects resolve location.hash to a DOM element with near-identical code, but one uses document.querySelector(hash) and the other document.getElementById(hash.slice(1)) — the kind of copy-paste drift that produced the querySelector bug flagged on the JS layout. Extract a single shared helper (e.g. in src/features/sicp/utils/SicpUtils.ts, alongside scrollRefIntoView) that both layouts call.

  • src/new_routes/sicpjs/_layout.tsx#L74-L83: replace the inline document.querySelector(hash) ?? refs.current[hash] lookup with a shared resolveHashElement(hash, refs) helper.
  • src/new_routes/sicpy/_layout.tsx#L42-L51: replace the inline document.getElementById(hash.slice(1)) ?? refs.current[hash] lookup with the same shared helper, ensuring both layouts use the safer getElementById-based implementation.
🤖 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 `@src/new_routes/sicpjs/_layout.tsx` around lines 74 - 83, Extract the shared
hash-to-element resolution into a helper such as resolveHashElement alongside
scrollRefIntoView in src/features/sicp/utils/SicpUtils.ts, using
getElementById(hash.slice(1)) with the refs fallback. Update the effects in
src/new_routes/sicpjs/_layout.tsx lines 74-83 and
src/new_routes/sicpy/_layout.tsx lines 42-51 to call this helper, removing their
duplicated inline lookups.

93-102: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Error → SicpErrorType mapping ternary is duplicated verbatim across both layouts. Both files run the exact same error.message === 'Not Found' ? ... : error instanceof ParseJsonError ? ... : ... chain to pick a SicpErrorType. Extracting this into a shared helper (e.g. mapSicpError(error): SicpErrorType, colocated with getSicpError/SicpErrorType in SicpErrors.tsx) keeps both layouts in sync and centralizes the (already separately-flagged) statusText-based 404 detection fix.

  • src/new_routes/sicpjs/_layout.tsx#L93-L102: replace the inline ternary with a call to a shared mapSicpError(error).
  • src/new_routes/sicpy/_layout.tsx#L61-L70: replace the inline ternary with the same shared mapSicpError(error) call.
🤖 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 `@src/new_routes/sicpjs/_layout.tsx` around lines 93 - 102, Extract the
duplicated error-to-SicpErrorType mapping into a shared mapSicpError(error)
helper colocated with getSicpError and SicpErrorType in SicpErrors.tsx. Replace
the inline ternary in src/new_routes/sicpjs/_layout.tsx lines 93-102 and
src/new_routes/sicpy/_layout.tsx lines 61-70 with calls to mapSicpError(error),
preserving the existing mappings and centralizing 404 detection.
src/pages/sicp/hooks/useSicpQuery.ts (1)

34-79: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

useSicpJsSectionQuery and useSicPySectionQuery are duplicated near-verbatim.

Both hooks share the same shape (refs, useQuery with enabled/select/ParseJsonError wrapping, and a call to useSicpSectionPersistence), differing only by which queries.sicp.section* variant and constants are used. Given the PR's stated goal of reducing duplication, this could be collapsed into a single parameterized hook.

♻️ Proposed refactor
+function useSicpVariantSectionQuery(
+  section: string | undefined,
+  opts: {
+    queryOptions: ReturnType<typeof queries.sicp.sectionJs>;
+    index: string;
+    cacheKey: string;
+    errorLabel: string;
+  },
+) {
+  const refs = useRef<Record<string, HTMLElement | null>>({});
+  const { data, error, isPending, isFetching } = useQuery({
+    ...opts.queryOptions,
+    enabled: !!section && section !== opts.index,
+    select: json => {
+      try {
+        return parseArr(json, refs);
+      } catch (e) {
+        throw new ParseJsonError(opts.errorLabel, { cause: e });
+      }
+    },
+  });
+  useSicpSectionPersistence(section, data, error, { key: opts.cacheKey, defaultSection: opts.index });
+  return { data, error, isPending, isFetching, refs };
+}
🤖 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 `@src/pages/sicp/hooks/useSicpQuery.ts` around lines 34 - 79, Extract the
shared query and persistence logic from useSicpJsSectionQuery and
useSicPySectionQuery into one parameterized hook that accepts the section query,
cache key, and default-section constant. Keep each public hook as a thin wrapper
supplying its respective queries.sicp.sectionJs/sectionPy configuration and
SICP_INDEX/SICPY_INDEX values, while preserving refs, enabled behavior,
ParseJsonError handling, and the returned query state.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/features/sicp/utils/SicpUtils.ts`:
- Around line 9-14: Update the scroll calculation around parentRef.current and
parent.scrollTo to use getBoundingClientRect() for both the target ref and
parent, deriving the target position relative to the parent’s current viewport
position and scroll state. Remove the offsetTop/window.scrollY-based relativeTop
calculation so scrolling remains correct regardless of intermediate positioning
or window scroll.

In `@src/new_routes/sicpjs/_layout.tsx`:
- Around line 79-83: Update the hash-anchor lookup in the effect using
location.hash to call document.getElementById with the leading “#” removed,
preventing selector parsing errors for IDs such as “#1.1” while preserving the
refs.current[hash] fallback and existing scrollRefIntoView flow.

In `@src/pages/sicp/hooks/useSicpQuery.ts`:
- Around line 10-32: Update useSicpSectionPersistence so the defaultSection
fallback is written only when error is present, not merely when data is
unavailable during loading. Preserve the existing section persistence for
successful data and default-section handling, and use the existing error
parameter in the conditional.

In `@src/queryKeys/sicp.ts`:
- Line 12: Update the fetch URL construction in the SICP JavaScript and Python
request paths to URI-encode the route-param `section` before concatenating it
with `baseUrlJs` or `baseUrlPy` and `extension`, preserving the existing fetch
behavior and signal handling.
- Around line 11-17: Update the query function’s HTTP error handling to throw an
error carrying res.status rather than using res.statusText as the message, then
change both SICP layout 404 checks to use error.status === 404. Preserve the
existing Not Found UI behavior for HTTP 404 responses.

---

Nitpick comments:
In `@src/commons/hooks/useLocalStorageState.ts`:
- Line 32: Update the useState initialization in useLocalStorageState to pass a
lazy initializer function that calls readLocalStorage(key, defaultValue),
ensuring the storage read and parsing occur only during initial state creation.

In `@src/new_routes/sicpjs/_layout.tsx`:
- Around line 74-83: Extract the shared hash-to-element resolution into a helper
such as resolveHashElement alongside scrollRefIntoView in
src/features/sicp/utils/SicpUtils.ts, using getElementById(hash.slice(1)) with
the refs fallback. Update the effects in src/new_routes/sicpjs/_layout.tsx lines
74-83 and src/new_routes/sicpy/_layout.tsx lines 42-51 to call this helper,
removing their duplicated inline lookups.
- Around line 93-102: Extract the duplicated error-to-SicpErrorType mapping into
a shared mapSicpError(error) helper colocated with getSicpError and
SicpErrorType in SicpErrors.tsx. Replace the inline ternary in
src/new_routes/sicpjs/_layout.tsx lines 93-102 and
src/new_routes/sicpy/_layout.tsx lines 61-70 with calls to mapSicpError(error),
preserving the existing mappings and centralizing 404 detection.

In `@src/pages/sicp/hooks/useSicpQuery.ts`:
- Around line 34-79: Extract the shared query and persistence logic from
useSicpJsSectionQuery and useSicPySectionQuery into one parameterized hook that
accepts the section query, cache key, and default-section constant. Keep each
public hook as a thin wrapper supplying its respective
queries.sicp.sectionJs/sectionPy configuration and SICP_INDEX/SICPY_INDEX
values, while preserving refs, enabled behavior, ParseJsonError handling, and
the returned query state.

In `@src/queryKeys/sicp.ts`:
- Around line 8-29: Extract the duplicated request logic from sicp.sectionJs and
sicp.sectionPy into a shared fetchSicpSection helper accepting baseUrl, section,
and signal. Have both query functions delegate to it, preserving the existing
response validation while standardizing JSON parsing through the helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 629178d0-6956-4952-9157-124c29832185

📥 Commits

Reviewing files that changed from the base of the PR and between 4980a1f and f030b3e.

📒 Files selected for processing (10)
  • src/commons/hooks/useLocalStorageState.ts
  • src/commons/utils/Hooks.ts
  • src/commons/utils/LocalStorageHelper.ts
  • src/features/sicp/utils/SicpUtils.ts
  • src/new_routes/_layout.tsx
  • src/new_routes/sicpjs/_layout.tsx
  • src/new_routes/sicpy/_layout.tsx
  • src/pages/sicp/hooks/useSicpQuery.ts
  • src/queryKeys/index.ts
  • src/queryKeys/sicp.ts
💤 Files with no reviewable changes (2)
  • src/commons/utils/LocalStorageHelper.ts
  • src/commons/utils/Hooks.ts

Comment on lines +9 to +14
const parent = parentRef.current!;
const relativeTop = window.scrollY > parent.offsetTop ? window.scrollY : parent.offsetTop;
parent.scrollTo({
behavior: 'smooth',
top: ref.offsetTop - relativeTop,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix unreliable scroll offset calculation.

Using offsetTop is fragile because it depends on the CSS position of intermediate ancestors (offsetParent), which may not be the parent. Additionally, mixing window.scrollY into the internal scroll target of a container (parent.scrollTo) is mathematically incorrect and will scroll to the wrong offset if the window has been scrolled.

Use getBoundingClientRect() to reliably compute the needed scroll offset relative to the current scroll position, regardless of CSS positioning or window scroll state.

🐛 Proposed fix
-  const parent = parentRef.current!;
-  const relativeTop = window.scrollY > parent.offsetTop ? window.scrollY : parent.offsetTop;
-  parent.scrollTo({
-    behavior: 'smooth',
-    top: ref.offsetTop - relativeTop,
-  });
+  const parent = parentRef.current!;
+  parent.scrollTo({
+    behavior: 'smooth',
+    top: parent.scrollTop + ref.getBoundingClientRect().top - parent.getBoundingClientRect().top,
+  });
📝 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
const parent = parentRef.current!;
const relativeTop = window.scrollY > parent.offsetTop ? window.scrollY : parent.offsetTop;
parent.scrollTo({
behavior: 'smooth',
top: ref.offsetTop - relativeTop,
});
const parent = parentRef.current!;
parent.scrollTo({
behavior: 'smooth',
top: parent.scrollTop + ref.getBoundingClientRect().top - parent.getBoundingClientRect().top,
});
🤖 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 `@src/features/sicp/utils/SicpUtils.ts` around lines 9 - 14, Update the scroll
calculation around parentRef.current and parent.scrollTo to use
getBoundingClientRect() for both the target ref and parent, deriving the target
position relative to the parent’s current viewport position and scroll state.
Remove the offsetTop/window.scrollY-based relativeTop calculation so scrolling
remains correct regardless of intermediate positioning or window scroll.

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.

Out of scope. This is a simple refactor. Valid though

Comment on lines 79 to +83
const hash = location.hash;
const elem =
(hash ? (document.querySelector(hash) as HTMLElement | null) : null) ?? refs.current[hash];
scrollRefIntoView(elem, parentRef);
}, [loading, location.hash]);
}, [isLoading, location.hash, refs]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File outline ==\n'
ast-grep outline src/new_routes/sicpjs/_layout.tsx --view expanded || true

printf '\n== Relevant lines in src/new_routes/sicpjs/_layout.tsx ==\n'
sed -n '1,140p' src/new_routes/sicpjs/_layout.tsx

printf '\n== Search for similar hash lookup patterns ==\n'
rg -n "document\.(querySelector|getElementById)\(hash|refs\.current\[hash\]|location\.hash" src -S

printf '\n== Check for SICP anchor naming patterns ==\n'
rg -n "1\.1|2\.2\.3|#\d+\.\d+|\bsection\b" src/new_routes/sicpjs -S

Repository: source-academy/frontend

Length of output: 6808


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

path = Path("src/new_routes/sicpjs/_layout.tsx")
text = path.read_text()

# Extract the relevant effect block for exact context.
m = re.search(r"const hash = location\.hash;.*?scrollRefIntoView\(elem, parentRef\);\s*}, \[isLoading, location\.hash, refs\]\);", text, re.S)
print("EFFECT_BLOCK_FOUND=", bool(m))
if m:
    print(m.group(0))

# Simulate CSS selector validity risk for common SICP-style hashes.
tests = ["`#1.1`", "`#2.2.3`", "`#abc`", "`#a.b`", "`#1`", "`#1`\\2"]
print("\nselector validity probes:")
for sel in tests:
    # We can't invoke DOM here, but we can note CSS.escape requirements.
    # Simple heuristic: leading digit or unescaped dot in id-like fragment is problematic for querySelector.
    frag = sel[1:]
    problematic = bool(re.match(r"^\d", frag)) or "." in frag
    print(sel, "problematic=", problematic)
PY

Repository: source-academy/frontend

Length of output: 575


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File outline ==\n'
ast-grep outline src/new_routes/sicpjs/_layout.tsx --view expanded || true

printf '\n== Relevant lines in src/new_routes/sicpjs/_layout.tsx ==\n'
sed -n '1,140p' src/new_routes/sicpjs/_layout.tsx

printf '\n== Similar lookup patterns ==\n'
rg -n "document\.(querySelector|getElementById)\(hash|refs\.current\[hash\]|location\.hash" src -S || true

printf '\n== Related SICP routes ==\n'
rg -n "SICP|sicpjs|sicpy" src -S || true

Repository: source-academy/frontend

Length of output: 14315


Use getElementById for hash anchors document.querySelector(hash) will throw on common SICP ids like #1.1 or #2.2.3. Use document.getElementById(hash.slice(1)) here and keep the refs.current[hash] fallback.

🤖 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 `@src/new_routes/sicpjs/_layout.tsx` around lines 79 - 83, Update the
hash-anchor lookup in the effect using location.hash to call
document.getElementById with the leading “#” removed, preventing selector
parsing errors for IDs such as “#1.1” while preserving the refs.current[hash]
fallback and existing scrollRefIntoView flow.

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.

Out of scope. This is a simple refactor

Comment thread src/pages/sicp/hooks/useSicpQuery.ts Resolved
Comment thread src/queryKeys/sicp.ts
Comment on lines +11 to +17
queryFn: async ({ signal }) => {
const res = await fetch(baseUrlJs + section + extension, { signal });
if (!res.ok) {
throw new Error(res.statusText);
}
return res.json();
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Inspecting relevant files...\n'
git ls-files 'src/queryKeys/sicp.ts' 'src/**/_layout.tsx' | sed 's/^/- /'

printf '\n--- src/queryKeys/sicp.ts ---\n'
cat -n src/queryKeys/sicp.ts

printf '\n--- search for Not Found handling ---\n'
rg -n "Not Found|error\.message|statusText|res\.status" src --glob '!**/dist/**' --glob '!**/build/**' --glob '!**/node_modules/**'

printf '\n--- locate sicp layout files ---\n'
fd -a '_layout.tsx' src

Repository: source-academy/frontend

Length of output: 3386


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- src/new_routes/sicpjs/_layout.tsx (relevant lines) ---\n'
sed -n '1,140p' src/new_routes/sicpjs/_layout.tsx | cat -n

printf '\n--- src/new_routes/sicpy/_layout.tsx (relevant lines) ---\n'
sed -n '1,120p' src/new_routes/sicpy/_layout.tsx | cat -n

printf '\n--- search for section param usage / definitions ---\n'
rg -n "sectionJs\\(|sectionPy\\(|params\\.section|useParams|section:" src/new_routes src/queryKeys src -g '!**/dist/**' -g '!**/build/**' -g '!**/node_modules/**'

Repository: source-academy/frontend

Length of output: 15199


404 handling should use the HTTP status, not statusText
Response.statusText is empty for HTTP/2 responses, so throw new Error(res.statusText) makes the error.message === 'Not Found' branch in both SICP layouts miss real 404s. Attach res.status to the thrown error and switch the UI to error.status === 404.

🤖 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 `@src/queryKeys/sicp.ts` around lines 11 - 17, Update the query function’s HTTP
error handling to throw an error carrying res.status rather than using
res.statusText as the message, then change both SICP layout 404 checks to use
error.status === 404. Preserve the existing Not Found UI behavior for HTTP 404
responses.

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.

Valid, but out of scope for this refactor

Comment thread src/queryKeys/sicp.ts
sectionJs: (section: string) => ({
queryKey: ['js', section],
queryFn: async ({ signal }) => {
const res = await fetch(baseUrlJs + section + extension, { signal });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Unescaped section interpolated directly into the fetch URL.

section comes from route params and is concatenated raw into baseUrlJs + section + extension / baseUrlPy + section + extension. Encode it to avoid malformed requests or unintended path segments (e.g. ../).

🛠️ Proposed fix
-      const res = await fetch(baseUrlJs + section + extension, { signal });
+      const res = await fetch(baseUrlJs + encodeURIComponent(section) + extension, { signal });

(apply similarly to the Py variant)

Also applies to: 22-22

🤖 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 `@src/queryKeys/sicp.ts` at line 12, Update the fetch URL construction in the
SICP JavaScript and Python request paths to URI-encode the route-param `section`
before concatenating it with `baseUrlJs` or `baseUrlPy` and `extension`,
preserving the existing fetch behavior and signal handling.

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.

Valid, but out of scope for this simple refactor

@RichDom2185 RichDom2185 changed the title Refactor SICP/SICPy layout hooks Refactor SICP/SICPy layout hooks and add caching Jul 14, 2026
@RichDom2185
RichDom2185 merged commit 98aee54 into master Jul 14, 2026
10 checks passed
@RichDom2185
RichDom2185 deleted the refactor-sicp-layout-hooks branch July 14, 2026 05:25
Isha-Sovasaria pushed a commit that referenced this pull request Jul 19, 2026
* Move useLocalStorageState to separate file

* Move local storage helpers to hook file

* Fix util duplication and match for parity

* Fix typo

* Standardize file layout

* Improve hook safety

* Use hook instead of low level effects

* Create SICP query keys

* Create SICP query hooks

* Use new hooks to reduce duplication

* Add special handling for index page

* Rename hook to be textbook agnostic

* Use global object to avoid stale references

* Fix loading edge case

* Prefer supplier function for performance
martin-henz added a commit that referenced this pull request Jul 29, 2026
* Add Louis bot rendering for sicpy

* Pass language to backend

* Update @sourceacademy/autocomplete digest to d476416 (#4049)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Update dependency react-simple-keyboard to v3.8.231 (#4064)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Update dependency @sentry/react to v10.63.0 (#4067)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Update dependency @rsbuild/core to v2.1.3 (#4059)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Fix crash when stored playground language state is invalid (#3646)

* Fix crash when stored playground language state is invalid

Validate the stored chapter+variant combination against ALL_LANGUAGES
when loading from localStorage. Falls back to the default language config
if the combination is invalid (e.g. chapter 1 + explicit-control).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fall back to defaultLanguageConfig for invalid stored language state

When the stored chapter+variant combination is not in ALL_LANGUAGES,
reset to `defaultLanguageConfig` (a known-valid pair) rather than to the
individual default context fields, so the fallback is explicitly "the
default language config" as the issue describes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Richard Dominick <34370238+RichDom2185@users.noreply.github.com>

* docs: add local backend setup and py-slang testing instructions (#4070)

* docs: add local backend setup steps to README

Fills the gap between "point REACT_APP_BACKEND_URL at localhost:4000"
and an actually-running backend, which otherwise just shows the
"under maintenance" page. Also corrects the postgres docker command
from the backend repo's own README, which has a stray -e flag before
-p 5432:5432 that prevents the port from actually being published.

* docs: add "Running your own py-slang" section

Mirrors the existing "Running your own js-slang" section, linking
out to py-slang's own README (added in a companion PR there) rather
than duplicating the instructions here.

* Update CONTRIBUTING.md

* Update CONTRIBUTING.md

* Update README.md

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Shrey Jain <101659971+Shrey5132@users.noreply.github.com>
Co-authored-by: Martin Henz <henz@comp.nus.edu.sg>

* Update README.md

* feat(cseMachine): rename global/program frames and annotate globals (#4068)

* feat(cseMachine): rename global/program frames and annotate globals

- Rename the top two CSE Machine frame labels to match Python's LEGB
  terminology: "Global" -> "Built-in functions", "Program" -> "Globals"
  (#4042).
- Thread the new optional CseSerializedEnvFrame.globalNames field
  (companion source-academy/plugins + source-academy/py-slang PRs)
  through CseSnapshotAdapter onto the fake Environment, and render it
  as a "globals: x, y" annotation at the top of a call frame the
  moment it's created, so it's clear those names resolve straight to
  the global frame instead of the usual enclosing-scope chain (#4041).
- Add @sourceacademy/common-cse-machine as a direct dependency: it's a
  peerDependency of @sourceacademy/web-cse-machine whose types
  frontend re-exports and uses directly, but frontend never installed
  it itself, so those types were silently resolving to `any` under
  skipLibCheck.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(cseMachine): stack globals annotation below frame name, fix overflow, and occlude arrows behind header text

- Move the globals annotation to its own row stacked below the frame
  name (rather than beside it or inside the box as a binding), since
  frame names are arbitrary function names of unpredictable length
  and can't safely share a row with anything else.
- Widen the frame box to fit the annotation label the same way binding
  text already widens it (capped at the same FrameDefaultWidth), so it
  no longer gets ellipsis-truncated or rendered left of the frame's
  own border when the label is longer than the box's other content.
- Add an opaque background rect behind the frame name and the globals
  annotation so the parent/tail arrow passing through that header gap
  is fully hidden behind the text, instead of showing through the
  empty space between glyphs (arrows are already layered behind
  content, but plain Konva text has no opaque fill of its own).

Closes #4069.

* fix(cseMachine): fix assignment animation crash for statement-only-assignment languages

InstrType.ASSIGNMENT constructed AssignmentAnimation with the
*current* (post-assignment) top-of-stash item, which is only valid
because a JS assignment *expression* re-pushes its value. A Python
assignment *statement* pushes nothing back, so the stash is genuinely
empty afterwards, the non-null assertion lied, and the constructor
threw reading `stashItem.index` on undefined — silently, since the
try/catch around CseAnimation.updateAnimation() had no logging.

Use the *previous* stash's top item instead, matching how
InstrType.POP (also a pure consumer) already does this correctly.
Also add the missing console.error so future animation regressions
in this path aren't invisible again.

Closes #4072.

* fix(cseMachine): stop the arrow-occlusion patch from being wider than the text it backs

Frame.tsx's header-text backing rects used Text.width(), which floors
at Config.TextMinWidth (30px) — a minimum meant for binding key/value
alignment, unrelated to how wide the text visually is. For a short
name like a one-letter function ("f", 9px of actual ink), that floor
is wider than Config.FramePaddingX (20px, the arrow's x-offset), so
the padded-out rect covered the arrow well past where the glyph
itself ends — an oversized, unnecessary gap in the arrow line even
when the text was nowhere near wide enough to actually cross it.

Use getTextWidth(text) directly instead, sized to what's actually
rendered.

* chore: bump common-cse-machine/web-cse-machine, drop published-type workaround casts

Now that @sourceacademy/common-cse-machine@0.2.0 (declaring
globalNames) and @sourceacademy/web-cse-machine@2.0.0 are actually
published, bump both dependencies and simplify the read sites that
were casting around the not-yet-published type.

Frame.tsx still needs one cast: `Env` (js-slang's real Environment
type) has no globalNames field regardless of any package version,
since it's an ad-hoc property CseSnapshotAdapter.ts stashes onto the
fake Environment object — updated that comment to say so accurately.

Also adds @sourceacademy/common-cse-machine to .yarnrc.yml's
npmPreapprovedPackages, matching the existing entries for
web-cse-machine/conductor/js-slang: yarn's npmMinimalAgeGate (3-day
quarantine on freshly published versions) was blocking install of the
version just published in the companion plugins release.

* fix(cseMachine): scope the Python LEGB frame rename to the Conductor snapshot path only

The #4042 rename ("Global" -> "Built-in functions", "Program" ->
"Globals") was motivated by Python's LEGB terminology, but Frame.tsx's
frameNames lookup applied unconditionally, so the legacy non-conductor
path (conductor.enable off, real js-slang interpreter) picked it up
too — a real regression against js-slang's own established naming,
confirmed live and flagged by Gemini's review on this PR.

Gate the rename on Layout.snapshotMode, which is true only while
rendering a Conductor snapshot (currently Python-only) and false for
the entire legacy path — evalCodeSaga never calls
CseMachine.renderSnapshot() when conductor.enable is off, so this is
a reliable, already-existing signal rather than a new one.

Other changes in this PR are left as-is: the globalNames annotation
is already inert for legacy js-slang Environments (they never carry
that ad-hoc field), the arrow-occlusion background-rect fix is a
general visual correctness fix unrelated to any language, and the
assignment-animation and frame-transition-timing fixes don't touch
frame naming at all.

Adds regression coverage on both sides of the gate: a real js-slang
EnvTree rendered outside snapshot mode keeps "Global"/"Program", and
a Python snapshot still gets "Built-in functions"/"Globals".

* fix(cseMachine): apply center-alignment offset to the globals annotation too

reassignCoordinatesX applied the center-alignment textOffset to the
frame name but not to _globalNamesText, leaving the annotation
flush-left under a centered name whenever center-alignment is on.
Flagged independently by CodeRabbit and Shrey's review on #4068.

Also extracts the headerRowHeight formula (duplicated between the
constructor and reassignCoordinatesY) into a single module-level
constant, per CodeRabbit's nitpick.

Added a regression test; confirmed it fails without the fix (30 vs
80) and passes with it.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Shrey Jain <101659971+Shrey5132@users.noreply.github.com>

* Show a popup for input() requests instead of hanging (py-slang#190) (#4084)

* Show a popup for input() requests instead of hanging (py-slang#190)

Wires up Conductor's new receiveInputRequest hook: a handleInputRequest
saga listens for input requests from the runner, shows the existing
PromptDialog popup, and answers via hostPlugin.sendInput. The home tab
and the viz CSE Machine tab share the same evalCodeConductorSaga run,
so this single fix covers both surfaces from the issue.

Also adds isWaitingForInput workspace state, used to suspend the
execTime watchdog while a popup is open so a user typing an answer
doesn't get their run force-stopped mid-prompt.

Points @sourceacademy/conductor at the local checkout (portal:) for
dev while the protocol change there is unreleased; repin to a real
published version once that lands upstream.

* chore: deps update

* Fix uncaught rejection when the input popup is dismissed via onClose

showSimplePromptDialog's underlying promise rejects if the dialog is
dismissed via its onClose path (e.g. clicking outside) rather than a
button response. The inner try/finally in handleInputRequest reset
isWaitingForInput on that path but didn't catch the rejection, so it
escaped the saga's while(true) loop and killed the task permanently -
leaving the runner blocked forever with no way to answer this or any
future input() call. Catch it and send an empty string, same as an
explicit Cancel.

Caught by gemini-code-assist review on PR #4084.

* Repin @sourceacademy/conductor to the published npm 0.6.0

The npm publish for conductor 0.6.0 landed, so the git-tag stopgap
pin isn't needed anymore - back to the normal semver-via-npm-registry
pin this repo used before. Verified: tsc --noEmit clean against the
real published package.

* feat(cseMachine): wire breakpoint step indices from the CSE snapshot channel (#4083)

* feat(cseMachine): wire breakpoint step indices from the CSE snapshot channel

CseMachineHostPlugin.receiveSnapshots now takes breakpointSteps as a second
argument (paired with the @sourceacademy/web-cse-machine protocol change),
and handleCseSnapshots dispatches WorkspaceActions.updateBreakpointSteps
with it alongside the existing snapshot/stepsTotal updates. The double-
chevron breakpoint-navigation buttons in SideContentCseMachine already
read props.breakpointSteps and are rendered unconditionally for the
conductor/Python flow — they just needed real data instead of an empty
array. No new UI. Depends on a py-slang change that detects breakpoint()
in the CSE machine (source-academy/py-slang issue #189).

package.json/yarn.lock currently point @sourceacademy/common-cse-machine
and @sourceacademy/web-cse-machine at a local yarn-link portal to
/home/vakshay/plugins for development; swap for a real published semver
range once that plugins PR merges.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: revert local yarn-link dev scaffolding for cse-machine packages

package.json/yarn.lock were pointing @sourceacademy/common-cse-machine and
@sourceacademy/web-cse-machine at a local yarn-link portal
(/home/vakshay/plugins) for development. That doesn't resolve for anyone
else or CI, and web-cse-machine can't be pinned to the plugins branch via
Yarn's git+workspace protocol either (it depends on the sibling
common-cse-machine workspace via workspace:*, and Yarn's single-workspace
git fetch doesn't build sibling workspaces first, so the prepack/rollup
step can't find its dist).

Reverting to the original published semver ranges so this PR's dependency
diff is clean. This PR depends on source-academy/plugins#55 (adds
breakpointSteps to the CSE snapshot protocol) merging and publishing to
npm before it will typecheck/build — opening as a draft until then.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: bump cse-machine deps to the published versions with breakpointSteps

@sourceacademy/common-cse-machine ^0.2.0 -> ^0.3.0 and
@sourceacademy/web-cse-machine ^2.0.0 -> ^3.0.0, now that
source-academy/plugins#55 has published (bumping the same two packages,
plus runner-cse-machine, to carry breakpointSteps over the CSE snapshot
channel). Unblocks this PR: tsc --noEmit passes against the real published
CseMachineHostPlugin.receiveSnapshots(snapshots, breakpointSteps) signature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: formattin

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: henz <henz@comp.nus.edu.sg>

* Updated the format in which language is sent to backend

* Remove chatbot from JS layout; stop sending language in chat APIs

* Update README.md

adding conductor info

* Remove unused scrollRefIntoView; inline scroll, clean unused Sicp layout symbols; add host plugin callbacks; relax CseMachineHostPlugin signature

* Fix language semantics

* Fix conflict

* Update README.md

* Fix conflict

* Fix conflict

* feat(cseMachine): wire breakpoint step indices from the CSE snapshot channel (#4083)

* feat(cseMachine): wire breakpoint step indices from the CSE snapshot channel

CseMachineHostPlugin.receiveSnapshots now takes breakpointSteps as a second
argument (paired with the @sourceacademy/web-cse-machine protocol change),
and handleCseSnapshots dispatches WorkspaceActions.updateBreakpointSteps
with it alongside the existing snapshot/stepsTotal updates. The double-
chevron breakpoint-navigation buttons in SideContentCseMachine already
read props.breakpointSteps and are rendered unconditionally for the
conductor/Python flow — they just needed real data instead of an empty
array. No new UI. Depends on a py-slang change that detects breakpoint()
in the CSE machine (source-academy/py-slang issue #189).

package.json/yarn.lock currently point @sourceacademy/common-cse-machine
and @sourceacademy/web-cse-machine at a local yarn-link portal to
/home/vakshay/plugins for development; swap for a real published semver
range once that plugins PR merges.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: revert local yarn-link dev scaffolding for cse-machine packages

package.json/yarn.lock were pointing @sourceacademy/common-cse-machine and
@sourceacademy/web-cse-machine at a local yarn-link portal
(/home/vakshay/plugins) for development. That doesn't resolve for anyone
else or CI, and web-cse-machine can't be pinned to the plugins branch via
Yarn's git+workspace protocol either (it depends on the sibling
common-cse-machine workspace via workspace:*, and Yarn's single-workspace
git fetch doesn't build sibling workspaces first, so the prepack/rollup
step can't find its dist).

Reverting to the original published semver ranges so this PR's dependency
diff is clean. This PR depends on source-academy/plugins#55 (adds
breakpointSteps to the CSE snapshot protocol) merging and publishing to
npm before it will typecheck/build — opening as a draft until then.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: bump cse-machine deps to the published versions with breakpointSteps

@sourceacademy/common-cse-machine ^0.2.0 -> ^0.3.0 and
@sourceacademy/web-cse-machine ^2.0.0 -> ^3.0.0, now that
source-academy/plugins#55 has published (bumping the same two packages,
plus runner-cse-machine, to carry breakpointSteps over the CSE snapshot
channel). Unblocks this PR: tsc --noEmit passes against the real published
CseMachineHostPlugin.receiveSnapshots(snapshots, breakpointSteps) signature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: formattin

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: henz <henz@comp.nus.edu.sg>

* Updated the format in which language is sent to backend

* Remove chatbot from JS layout; stop sending language in chat APIs

* Update blueprintjs to v6.17.2 (#4060)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Update dependency @rsbuild/core to v2.1.5 (#4077)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Update dependency react-simple-keyboard to v3.8.233 (#4081)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Update dependency @rsbuild/plugin-sass to v2.0.1 (#4075)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Update dependency @rsbuild/plugin-svgr to v2.0.5 (#4076)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Update vitest monorepo to v4.1.10 (#4082)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Update dependency prettier to v3.9.5 (#4087)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Follow Redux Toolkit conventions (#4089)

* Rename useTypedSelector to useAppSelector

* Create useAppDispatch hook

* Update ESLint import rule

* Migrate some components to use useAppDispatch

* Migrate more components to use useAppDispatch

* Migrate even more files to use useAppDispatch

* Fix imports

* Fix errors post-rebase

* Clean up tests

* Remove duplicated code

* Fix remaining errors

* Migrate conflicting files

* Undo incorrect change post-rebase

* Fix remaining error

* Fix ESLint errors

* Set up Knip for codebase maintenance (#4052)

* Install knip

* Deduplicate dependencies

* Add knip config

* Delete some unused files

* Delete FaceAPI side content

* Delete editable test case side content

* Remove unused export

* Clean up config warnings

* Address phantom dependencies

* Add knip docs in README

* Fix lockfile post-rebase

* Reformat README

* Resolve README discrepancies

* Fix README confusion

* Migrate from Prettier to oxfmt (#4090)

* Update .gitattributes for LF normalization

* Install oxfmt

* Migrate Prettier config to oxfmt

* Add ignore patterns

* Update VSCode configuration

* Reformat files

* Update package scripts and hooks

* Reformat Renovate config

* Group rsbuild ecosystem updates together

* Refactor SICP/SICPy layout hooks and add caching (#4092)

* Move useLocalStorageState to separate file

* Move local storage helpers to hook file

* Fix util duplication and match for parity

* Fix typo

* Standardize file layout

* Improve hook safety

* Use hook instead of low level effects

* Create SICP query keys

* Create SICP query hooks

* Use new hooks to reduce duplication

* Add special handling for index page

* Rename hook to be textbook agnostic

* Use global object to avoid stale references

* Fix loading edge case

* Prefer supplier function for performance

* feat: move Chatbot from JS layout to Python layout

* refactor(sicp): move Chatbot from JS layout to Python layout

* style(sicp): format Python layout after move

* Fix link issues

* Fix react-router useParams typing in sicpjs layout

* Fix react-router useParams typing and import formatting in sicpy layout

* Format sicpy layout with oxfmt

* Fix crash when stored playground language state is invalid (#3646)

* Fix crash when stored playground language state is invalid

Validate the stored chapter+variant combination against ALL_LANGUAGES
when loading from localStorage. Falls back to the default language config
if the combination is invalid (e.g. chapter 1 + explicit-control).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fall back to defaultLanguageConfig for invalid stored language state

When the stored chapter+variant combination is not in ALL_LANGUAGES,
reset to `defaultLanguageConfig` (a known-valid pair) rather than to the
individual default context fields, so the fallback is explicitly "the
default language config" as the issue describes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Richard Dominick <34370238+RichDom2185@users.noreply.github.com>

* docs: add local backend setup and py-slang testing instructions (#4070)

* docs: add local backend setup steps to README

Fills the gap between "point REACT_APP_BACKEND_URL at localhost:4000"
and an actually-running backend, which otherwise just shows the
"under maintenance" page. Also corrects the postgres docker command
from the backend repo's own README, which has a stray -e flag before
-p 5432:5432 that prevents the port from actually being published.

* docs: add "Running your own py-slang" section

Mirrors the existing "Running your own js-slang" section, linking
out to py-slang's own README (added in a companion PR there) rather
than duplicating the instructions here.

* Update CONTRIBUTING.md

* Update CONTRIBUTING.md

* Update README.md

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Shrey Jain <101659971+Shrey5132@users.noreply.github.com>
Co-authored-by: Martin Henz <henz@comp.nus.edu.sg>

* Update README.md

* feat(cseMachine): rename global/program frames and annotate globals (#4068)

* feat(cseMachine): rename global/program frames and annotate globals

- Rename the top two CSE Machine frame labels to match Python's LEGB
  terminology: "Global" -> "Built-in functions", "Program" -> "Globals"
  (#4042).
- Thread the new optional CseSerializedEnvFrame.globalNames field
  (companion source-academy/plugins + source-academy/py-slang PRs)
  through CseSnapshotAdapter onto the fake Environment, and render it
  as a "globals: x, y" annotation at the top of a call frame the
  moment it's created, so it's clear those names resolve straight to
  the global frame instead of the usual enclosing-scope chain (#4041).
- Add @sourceacademy/common-cse-machine as a direct dependency: it's a
  peerDependency of @sourceacademy/web-cse-machine whose types
  frontend re-exports and uses directly, but frontend never installed
  it itself, so those types were silently resolving to `any` under
  skipLibCheck.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(cseMachine): stack globals annotation below frame name, fix overflow, and occlude arrows behind header text

- Move the globals annotation to its own row stacked below the frame
  name (rather than beside it or inside the box as a binding), since
  frame names are arbitrary function names of unpredictable length
  and can't safely share a row with anything else.
- Widen the frame box to fit the annotation label the same way binding
  text already widens it (capped at the same FrameDefaultWidth), so it
  no longer gets ellipsis-truncated or rendered left of the frame's
  own border when the label is longer than the box's other content.
- Add an opaque background rect behind the frame name and the globals
  annotation so the parent/tail arrow passing through that header gap
  is fully hidden behind the text, instead of showing through the
  empty space between glyphs (arrows are already layered behind
  content, but plain Konva text has no opaque fill of its own).

Closes #4069.

* fix(cseMachine): fix assignment animation crash for statement-only-assignment languages

InstrType.ASSIGNMENT constructed AssignmentAnimation with the
*current* (post-assignment) top-of-stash item, which is only valid
because a JS assignment *expression* re-pushes its value. A Python
assignment *statement* pushes nothing back, so the stash is genuinely
empty afterwards, the non-null assertion lied, and the constructor
threw reading `stashItem.index` on undefined — silently, since the
try/catch around CseAnimation.updateAnimation() had no logging.

Use the *previous* stash's top item instead, matching how
InstrType.POP (also a pure consumer) already does this correctly.
Also add the missing console.error so future animation regressions
in this path aren't invisible again.

Closes #4072.

* fix(cseMachine): stop the arrow-occlusion patch from being wider than the text it backs

Frame.tsx's header-text backing rects used Text.width(), which floors
at Config.TextMinWidth (30px) — a minimum meant for binding key/value
alignment, unrelated to how wide the text visually is. For a short
name like a one-letter function ("f", 9px of actual ink), that floor
is wider than Config.FramePaddingX (20px, the arrow's x-offset), so
the padded-out rect covered the arrow well past where the glyph
itself ends — an oversized, unnecessary gap in the arrow line even
when the text was nowhere near wide enough to actually cross it.

Use getTextWidth(text) directly instead, sized to what's actually
rendered.

* chore: bump common-cse-machine/web-cse-machine, drop published-type workaround casts

Now that @sourceacademy/common-cse-machine@0.2.0 (declaring
globalNames) and @sourceacademy/web-cse-machine@2.0.0 are actually
published, bump both dependencies and simplify the read sites that
were casting around the not-yet-published type.

Frame.tsx still needs one cast: `Env` (js-slang's real Environment
type) has no globalNames field regardless of any package version,
since it's an ad-hoc property CseSnapshotAdapter.ts stashes onto the
fake Environment object — updated that comment to say so accurately.

Also adds @sourceacademy/common-cse-machine to .yarnrc.yml's
npmPreapprovedPackages, matching the existing entries for
web-cse-machine/conductor/js-slang: yarn's npmMinimalAgeGate (3-day
quarantine on freshly published versions) was blocking install of the
version just published in the companion plugins release.

* fix(cseMachine): scope the Python LEGB frame rename to the Conductor snapshot path only

The #4042 rename ("Global" -> "Built-in functions", "Program" ->
"Globals") was motivated by Python's LEGB terminology, but Frame.tsx's
frameNames lookup applied unconditionally, so the legacy non-conductor
path (conductor.enable off, real js-slang interpreter) picked it up
too — a real regression against js-slang's own established naming,
confirmed live and flagged by Gemini's review on this PR.

Gate the rename on Layout.snapshotMode, which is true only while
rendering a Conductor snapshot (currently Python-only) and false for
the entire legacy path — evalCodeSaga never calls
CseMachine.renderSnapshot() when conductor.enable is off, so this is
a reliable, already-existing signal rather than a new one.

Other changes in this PR are left as-is: the globalNames annotation
is already inert for legacy js-slang Environments (they never carry
that ad-hoc field), the arrow-occlusion background-rect fix is a
general visual correctness fix unrelated to any language, and the
assignment-animation and frame-transition-timing fixes don't touch
frame naming at all.

Adds regression coverage on both sides of the gate: a real js-slang
EnvTree rendered outside snapshot mode keeps "Global"/"Program", and
a Python snapshot still gets "Built-in functions"/"Globals".

* fix(cseMachine): apply center-alignment offset to the globals annotation too

reassignCoordinatesX applied the center-alignment textOffset to the
frame name but not to _globalNamesText, leaving the annotation
flush-left under a centered name whenever center-alignment is on.
Flagged independently by CodeRabbit and Shrey's review on #4068.

Also extracts the headerRowHeight formula (duplicated between the
constructor and reassignCoordinatesY) into a single module-level
constant, per CodeRabbit's nitpick.

Added a regression test; confirmed it fails without the fix (30 vs
80) and passes with it.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Shrey Jain <101659971+Shrey5132@users.noreply.github.com>

* Show a popup for input() requests instead of hanging (py-slang#190) (#4084)

* Show a popup for input() requests instead of hanging (py-slang#190)

Wires up Conductor's new receiveInputRequest hook: a handleInputRequest
saga listens for input requests from the runner, shows the existing
PromptDialog popup, and answers via hostPlugin.sendInput. The home tab
and the viz CSE Machine tab share the same evalCodeConductorSaga run,
so this single fix covers both surfaces from the issue.

Also adds isWaitingForInput workspace state, used to suspend the
execTime watchdog while a popup is open so a user typing an answer
doesn't get their run force-stopped mid-prompt.

Points @sourceacademy/conductor at the local checkout (portal:) for
dev while the protocol change there is unreleased; repin to a real
published version once that lands upstream.

* chore: deps update

* Fix uncaught rejection when the input popup is dismissed via onClose

showSimplePromptDialog's underlying promise rejects if the dialog is
dismissed via its onClose path (e.g. clicking outside) rather than a
button response. The inner try/finally in handleInputRequest reset
isWaitingForInput on that path but didn't catch the rejection, so it
escaped the saga's while(true) loop and killed the task permanently -
leaving the runner blocked forever with no way to answer this or any
future input() call. Catch it and send an empty string, same as an
explicit Cancel.

Caught by gemini-code-assist review on PR #4084.

* Repin @sourceacademy/conductor to the published npm 0.6.0

The npm publish for conductor 0.6.0 landed, so the git-tag stopgap
pin isn't needed anymore - back to the normal semver-via-npm-registry
pin this repo used before. Verified: tsc --noEmit clean against the
real published package.

* feat(cseMachine): wire breakpoint step indices from the CSE snapshot channel (#4083)

* feat(cseMachine): wire breakpoint step indices from the CSE snapshot channel

CseMachineHostPlugin.receiveSnapshots now takes breakpointSteps as a second
argument (paired with the @sourceacademy/web-cse-machine protocol change),
and handleCseSnapshots dispatches WorkspaceActions.updateBreakpointSteps
with it alongside the existing snapshot/stepsTotal updates. The double-
chevron breakpoint-navigation buttons in SideContentCseMachine already
read props.breakpointSteps and are rendered unconditionally for the
conductor/Python flow — they just needed real data instead of an empty
array. No new UI. Depends on a py-slang change that detects breakpoint()
in the CSE machine (source-academy/py-slang issue #189).

package.json/yarn.lock currently point @sourceacademy/common-cse-machine
and @sourceacademy/web-cse-machine at a local yarn-link portal to
/home/vakshay/plugins for development; swap for a real published semver
range once that plugins PR merges.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: revert local yarn-link dev scaffolding for cse-machine packages

package.json/yarn.lock were pointing @sourceacademy/common-cse-machine and
@sourceacademy/web-cse-machine at a local yarn-link portal
(/home/vakshay/plugins) for development. That doesn't resolve for anyone
else or CI, and web-cse-machine can't be pinned to the plugins branch via
Yarn's git+workspace protocol either (it depends on the sibling
common-cse-machine workspace via workspace:*, and Yarn's single-workspace
git fetch doesn't build sibling workspaces first, so the prepack/rollup
step can't find its dist).

Reverting to the original published semver ranges so this PR's dependency
diff is clean. This PR depends on source-academy/plugins#55 (adds
breakpointSteps to the CSE snapshot protocol) merging and publishing to
npm before it will typecheck/build — opening as a draft until then.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: bump cse-machine deps to the published versions with breakpointSteps

@sourceacademy/common-cse-machine ^0.2.0 -> ^0.3.0 and
@sourceacademy/web-cse-machine ^2.0.0 -> ^3.0.0, now that
source-academy/plugins#55 has published (bumping the same two packages,
plus runner-cse-machine, to carry breakpointSteps over the CSE snapshot
channel). Unblocks this PR: tsc --noEmit passes against the real published
CseMachineHostPlugin.receiveSnapshots(snapshots, breakpointSteps) signature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: formattin

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: henz <henz@comp.nus.edu.sg>

* Updated the format in which language is sent to backend

* Remove chatbot from JS layout; stop sending language in chat APIs

* Remove unused scrollRefIntoView; inline scroll, clean unused Sicp layout symbols; add host plugin callbacks; relax CseMachineHostPlugin signature

* Fix language semantics

* Fix conflict

* Update README.md

* Fix conflict

* Fix conflict

* feat(cseMachine): wire breakpoint step indices from the CSE snapshot channel (#4083)

* feat(cseMachine): wire breakpoint step indices from the CSE snapshot channel

CseMachineHostPlugin.receiveSnapshots now takes breakpointSteps as a second
argument (paired with the @sourceacademy/web-cse-machine protocol change),
and handleCseSnapshots dispatches WorkspaceActions.updateBreakpointSteps
with it alongside the existing snapshot/stepsTotal updates. The double-
chevron breakpoint-navigation buttons in SideContentCseMachine already
read props.breakpointSteps and are rendered unconditionally for the
conductor/Python flow — they just needed real data instead of an empty
array. No new UI. Depends on a py-slang change that detects breakpoint()
in the CSE machine (source-academy/py-slang issue #189).

package.json/yarn.lock currently point @sourceacademy/common-cse-machine
and @sourceacademy/web-cse-machine at a local yarn-link portal to
/home/vakshay/plugins for development; swap for a real published semver
range once that plugins PR merges.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: revert local yarn-link dev scaffolding for cse-machine packages

package.json/yarn.lock were pointing @sourceacademy/common-cse-machine and
@sourceacademy/web-cse-machine at a local yarn-link portal
(/home/vakshay/plugins) for development. That doesn't resolve for anyone
else or CI, and web-cse-machine can't be pinned to the plugins branch via
Yarn's git+workspace protocol either (it depends on the sibling
common-cse-machine workspace via workspace:*, and Yarn's single-workspace
git fetch doesn't build sibling workspaces first, so the prepack/rollup
step can't find its dist).

Reverting to the original published semver ranges so this PR's dependency
diff is clean. This PR depends on source-academy/plugins#55 (adds
breakpointSteps to the CSE snapshot protocol) merging and publishing to
npm before it will typecheck/build — opening as a draft until then.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: bump cse-machine deps to the published versions with breakpointSteps

@sourceacademy/common-cse-machine ^0.2.0 -> ^0.3.0 and
@sourceacademy/web-cse-machine ^2.0.0 -> ^3.0.0, now that
source-academy/plugins#55 has published (bumping the same two packages,
plus runner-cse-machine, to carry breakpointSteps over the CSE snapshot
channel). Unblocks this PR: tsc --noEmit passes against the real published
CseMachineHostPlugin.receiveSnapshots(snapshots, breakpointSteps) signature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: formattin

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: henz <henz@comp.nus.edu.sg>

* Updated the format in which language is sent to backend

* Remove chatbot from JS layout; stop sending language in chat APIs

* feat: move Chatbot from JS layout to Python layout

# Conflicts:
#	src/new_routes/sicpjs/_layout.tsx
#	src/new_routes/sicpy/_layout.tsx

* refactor(sicp): move Chatbot from JS layout to Python layout

* style(sicp): format Python layout after move

* Fix link issues

* Fix react-router useParams typing in sicpjs layout

* Fix react-router useParams typing and import formatting in sicpy layout

* Format sicpy layout with oxfmt

* Changes as requested

* Configure Louis chatbot per course

* Format SICP JavaScript layout

* Remove unrelated PR artifacts

* fixed code review comment

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Martin Henz <henz@comp.nus.edu.sg>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Richard Dominick <34370238+RichDom2185@users.noreply.github.com>
Co-authored-by: Akshay <131676168+Akshay-2007-1@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Shrey Jain <101659971+Shrey5132@users.noreply.github.com>
Co-authored-by: Zhang Yilin <yilinzh54@gmail.com>
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