Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 11 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,26 @@ import SampleSize from './pages/SampleSize';
import Review from './pages/Review';
import AvatarStudio from './pages/AvatarStudio';
import { fetchDataset, prepareSession } from './r/session';
import { getWebR } from './r/webrClient';
import { getWebR, onRestart } from './r/webrClient';
import './App.css';

export default function App() {
const [restarts, setRestarts] = useState(0);
// Spec 3.5: webR starts in the background on first app load, not when the
// first lesson opens. Mounted here rather than in main.tsx so it is testable
// by rendering App. Both calls are memoised, so StrictMode's double mount and
// useLessonSession's later await all share this one boot.
useEffect(() => {
// Not swallowing: prepareSession's own catch has already set the error
// status that RStatus renders. This only stops an unhandled rejection.
void getWebR().then((r) => prepareSession(r, fetchDataset)).catch(() => {});
const boot = () => void getWebR().then((r) => prepareSession(r, fetchDataset)).catch(() => {});
boot();
// After "Restart R": remount the page, so every lesson environment, code
// block and console that held the old R starts over on the new one.
return onRestart(() => {
setRestarts((count) => count + 1);
boot();
});
}, []);

useEffect(() => {
Expand Down Expand Up @@ -108,7 +116,7 @@ export default function App() {
<main id="main" tabIndex={-1} className="app-main">
<RStatus />
<div ref={page} className={`page page-${pathname.split('/')[1] || 'home'}`}>
<Routes>
<Routes key={restarts}>
<Route path="/" element={<Home />} />
<Route path="/lesson/:lessonId" element={<Lesson />} />
<Route path="/workspace" element={<RWorkspace />} />
Expand Down
17 changes: 3 additions & 14 deletions src/components/RStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,5 @@
import { useEffect, useState } from 'react';
import { onStatus, type RStatus as Status } from '../r/webrClient';

/**
* Restarting reloads the page rather than respawning the worker in place.
* webR's PostMessage channel cannot interrupt running R code, so a student's
* infinite loop has no other escape. A reload clears the dead lesson
* environment, the memoised session promise and stale component state in one
* move, and nothing is lost because code drafts live in localStorage.
*/
function restart() {
window.location.reload();
}
import { onStatus, restartR, type RStatus as Status } from '../r/webrClient';

export default function RStatus() {
const [status, setStatus] = useState<Status>({ phase: 'idle' });
Expand All @@ -21,15 +10,15 @@ export default function RStatus() {
if (status.phase === 'idle') return null;

if (status.phase === 'ready') {
return <div className="r-status ready"><span>R is ready</span><button type="button" onClick={restart} title="Use this if R stops responding">Restart R</button></div>;
return <div className="r-status ready"><span>R is ready</span><button type="button" onClick={restartR} title="Use this if R stops responding">Restart R</button></div>;
}

if (status.phase === 'error') {
return (
<div className="r-status error">
<p>R could not start. StatLab needs a recent browser and an internet connection the first time it loads. You can still read the lessons and answer the questions.</p>
<p className="r-status-detail">{status.detail}</p>
<button type="button" onClick={restart}>Try again</button>
<button type="button" onClick={restartR}>Try again</button>
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Lesson.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ vi.mock('../content/manifest', async (importOriginal) => {

const getWebR = vi.hoisted(() => vi.fn());
const setStatus = vi.hoisted(() => vi.fn());
vi.mock('../r/webrClient', () => ({ getWebR, setStatus }));
vi.mock('../r/webrClient', () => ({ getWebR, setStatus, onRestart: () => () => {} }));

const prepareSession = vi.hoisted(() => vi.fn());
const fetchDataset = vi.hoisted(() => vi.fn());
Expand Down
8 changes: 7 additions & 1 deletion src/r/session.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { WebR } from 'webr';
import { setStatus } from './webrClient';
import { onRestart, setStatus } from './webrClient';

/**
* Spec §3.5: installed at boot. The 40 MB figure the spec quotes was measured
Expand Down Expand Up @@ -131,6 +131,12 @@ export async function mountDatasets(

let prepared: Promise<void> | null = null;

// Both caches belong to one webR instance; a restarted R has nothing installed.
onRestart(() => {
prepared = null;
packagePromises = new Map();
});

/** Install packages and mount datasets exactly once per webR instance. */
export function prepareSession(
webR: WebR,
Expand Down
7 changes: 6 additions & 1 deletion src/r/uploads.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { WebR } from 'webr';
import { DATA_DIR, DATASET_FILES, ensurePackages } from './session';
import { setStatus } from './webrClient';
import { onRestart, setStatus } from './webrClient';
import { deleteStoredFile, loadStoredFiles, saveStoredFile } from '../state/uploadStore';

/**
Expand Down Expand Up @@ -122,6 +122,11 @@ async function installExcelReader(webR: WebR): Promise<void> {

let restored: Promise<void> | null = null;

// A restarted R starts with an empty data folder, so the kept files go back in.
onRestart(() => {
restored = null;
});

/**
* Puts the files kept from earlier visits back into R's data folder, once per
* page load. Never rejects: a browser that refuses storage (a private window,
Expand Down
3 changes: 2 additions & 1 deletion src/r/useLessonSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ export function useLessonSession(
// which derives from these, so an env being freed must never read as ready.
setEnv(null);
setWebR(null);
if (created && createdBy) void destroyEnv(createdBy, created);
// After a restart the old R is gone along with its environments.
if (created && createdBy) destroyEnv(createdBy, created).catch(() => {});
};
}, [key]);

Expand Down
26 changes: 26 additions & 0 deletions src/r/webrClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,29 @@ describe('singleton and retry', () => {
expect(MockedWebR).toHaveBeenCalledTimes(2); // constructor called twice
});
});

describe('restartR', () => {
beforeEach(() => {
vi.resetModules();
});

test('closes the running R, tells listeners, and boots a fresh one on the next request', async () => {
vi.doMock('webr', () => ({
WebR: class {
init = vi.fn(async () => {});
close = vi.fn();
},
}));
const { getWebR, onRestart, restartR } = await import('./webrClient');
const first = await getWebR();
const heard = vi.fn();
onRestart(heard);

restartR();

expect(first.close).toHaveBeenCalledTimes(1);
expect(heard).toHaveBeenCalledTimes(1);
const second = await getWebR();
expect(second).not.toBe(first);
});
});
22 changes: 22 additions & 0 deletions src/r/webrClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ let instance: WebR | null = null;
let booting: Promise<WebR> | null = null;
let status: RStatus = { phase: 'idle' };
const listeners = new Set<(s: RStatus) => void>();
const restartListeners = new Set<() => void>();

export function getStatus(): RStatus {
return status;
Expand Down Expand Up @@ -55,3 +56,24 @@ export function getWebR(): Promise<WebR> {
return booting;
}


/** Called on every restart, so anything cached per webR instance can forget it. */
export function onRestart(fn: () => void): () => void {
restartListeners.add(fn);
return () => restartListeners.delete(fn);
}

/**
* Throws the running R away and starts a fresh one, without reloading the page.
* webR's PostMessage channel cannot interrupt running R code, but closing ends
* the worker even in the middle of a student's infinite loop. Listeners reset
* their per-instance caches and remount what held the old R; code drafts live
* in localStorage, so nothing typed is lost.
*/
export function restartR(): void {
const old = instance;
instance = null;
booting = null;
old?.close();
for (const fn of restartListeners) fn();
}