From c7ee70fe5ac2193da122c05c0f79c9c5bee648c7 Mon Sep 17 00:00:00 2001 From: Peter Slijkhuis Date: Wed, 30 Sep 2026 15:54:07 +0000 Subject: [PATCH] Restart R without reloading the page Restart R now closes the webR worker, which stops R even in the middle of an endless loop, and boots a fresh one in the same page. The per-instance caches (session setup, installed packages, restored uploads) reset, and the page content remounts so lessons and the R Workspace pick up the new R. Drafts and scripts are kept. --- src/App.tsx | 14 +++++++++++--- src/components/RStatus.tsx | 17 +++-------------- src/pages/Lesson.test.tsx | 2 +- src/r/session.ts | 8 +++++++- src/r/uploads.ts | 7 ++++++- src/r/useLessonSession.ts | 3 ++- src/r/webrClient.test.ts | 26 ++++++++++++++++++++++++++ src/r/webrClient.ts | 22 ++++++++++++++++++++++ 8 files changed, 78 insertions(+), 21 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e955874..81406db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,10 +15,11 @@ 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 @@ -26,7 +27,14 @@ export default function App() { 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(() => { @@ -108,7 +116,7 @@ export default function App() {
- + } /> } /> } /> diff --git a/src/components/RStatus.tsx b/src/components/RStatus.tsx index 711b7f7..b344be5 100644 --- a/src/components/RStatus.tsx +++ b/src/components/RStatus.tsx @@ -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({ phase: 'idle' }); @@ -21,7 +10,7 @@ export default function RStatus() { if (status.phase === 'idle') return null; if (status.phase === 'ready') { - return
R is ready
; + return
R is ready
; } if (status.phase === 'error') { @@ -29,7 +18,7 @@ export default function RStatus() {

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.

{status.detail}

- +
); } diff --git a/src/pages/Lesson.test.tsx b/src/pages/Lesson.test.tsx index ca66899..951854e 100644 --- a/src/pages/Lesson.test.tsx +++ b/src/pages/Lesson.test.tsx @@ -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()); diff --git a/src/r/session.ts b/src/r/session.ts index f8a8e13..e3c6440 100644 --- a/src/r/session.ts +++ b/src/r/session.ts @@ -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 @@ -131,6 +131,12 @@ export async function mountDatasets( let prepared: Promise | 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, diff --git a/src/r/uploads.ts b/src/r/uploads.ts index 6616b7f..27e6600 100644 --- a/src/r/uploads.ts +++ b/src/r/uploads.ts @@ -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'; /** @@ -122,6 +122,11 @@ async function installExcelReader(webR: WebR): Promise { let restored: Promise | 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, diff --git a/src/r/useLessonSession.ts b/src/r/useLessonSession.ts index 63d0f0b..f25f6d9 100644 --- a/src/r/useLessonSession.ts +++ b/src/r/useLessonSession.ts @@ -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]); diff --git a/src/r/webrClient.test.ts b/src/r/webrClient.test.ts index d1279d6..e4ec0d7 100644 --- a/src/r/webrClient.test.ts +++ b/src/r/webrClient.test.ts @@ -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); + }); +}); diff --git a/src/r/webrClient.ts b/src/r/webrClient.ts index 9ebe6d1..4e30461 100644 --- a/src/r/webrClient.ts +++ b/src/r/webrClient.ts @@ -18,6 +18,7 @@ let instance: WebR | null = null; let booting: Promise | 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; @@ -55,3 +56,24 @@ export function getWebR(): Promise { 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(); +}