don't render EditorState before having attempted to recover state from URL hash

This commit is contained in:
Joeri Exelmans 2025-10-24 09:01:17 +02:00
parent e527baf81f
commit 74f4c3bead

View file

@ -71,7 +71,7 @@ function getPlantState<T>(plant: Plant<T>, trace: TraceItem[], idx: number): T |
export function App() { export function App() {
const [insertMode, setInsertMode] = useState<InsertMode>("and"); const [insertMode, setInsertMode] = useState<InsertMode>("and");
const [editHistory, setEditHistory] = useState<EditHistory>({current: emptyState, history: [], future: []}); const [editHistory, setEditHistory] = useState<EditHistory|null>(null);
const [trace, setTrace] = useState<TraceState|null>(null); const [trace, setTrace] = useState<TraceState|null>(null);
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0}); const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
const [modal, setModal] = useState<ReactElement|null>(null); const [modal, setModal] = useState<ReactElement|null>(null);
@ -82,43 +82,50 @@ export function App() {
const plant = plants.find(([pn, p]) => pn === plantName)![1]; const plant = plants.find(([pn, p]) => pn === plantName)![1];
const editorState = editHistory.current; const editorState = editHistory && editHistory.current;
const setEditorState = useCallback((cb: (value: VisualEditorState) => VisualEditorState) => { const setEditorState = useCallback((cb: (value: VisualEditorState) => VisualEditorState) => {
setEditHistory(historyState => ({...historyState, current: cb(historyState.current)})); setEditHistory(historyState => historyState && ({...historyState, current: cb(historyState.current)}));
}, [setEditHistory]); }, [setEditHistory]);
// recover editor state from URL - we need an effect here because decompression is asynchronous // recover editor state from URL - we need an effect here because decompression is asynchronous
useEffect(() => { useEffect(() => {
try { console.log('recovering state...');
const compressedState = window.location.hash.slice(1); const compressedState = window.location.hash.slice(1);
if (compressedState.length === 0) {
console.log("no state to recover");
setEditHistory(() => ({current: emptyState, history: [], future: []}));
return;
}
let compressedBuffer;
try {
compressedBuffer = Uint8Array.fromBase64(compressedState); // may throw
} catch (e) {
console.error("failed to recover state:", e);
setEditHistory(() => ({current: emptyState, history: [], future: []}));
return;
}
const ds = new DecompressionStream("deflate"); const ds = new DecompressionStream("deflate");
const writer = ds.writable.getWriter(); const writer = ds.writable.getWriter();
writer.write(Uint8Array.fromBase64(compressedState)).catch(e => { writer.write(compressedBuffer).catch(() => {}); // any promise rejections will be detected when we try to read
console.error("could not recover state:", e); writer.close().catch(() => {});
}); new Response(ds.readable).arrayBuffer()
writer.close().catch(e => { .then(decompressedBuffer => {
console.error("could not recover state:", e);
});
new Response(ds.readable).arrayBuffer().then(decompressedBuffer => {
try {
const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer)); const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer));
setEditorState(() => recoveredState); setEditHistory(() => ({current: recoveredState, history: [], future: []}));
} })
catch (e) { .catch(e => {
console.error("could not recover state:", e); console.error("failed to recover state:", e);
} setEditHistory({current: emptyState, history: [], future: []});
}).catch(e => {
console.error("could not recover state:", e);
}); });
}
catch (e) {
console.error("could not recover state:", e);
}
}, []); }, []);
// save editor state in URL // save editor state in URL
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (editorState === null) {
window.location.hash = "#";
return;
}
const serializedState = JSON.stringify(editorState); const serializedState = JSON.stringify(editorState);
const stateBuffer = new TextEncoder().encode(serializedState); const stateBuffer = new TextEncoder().encode(serializedState);
const cs = new CompressionStream("deflate"); const cs = new CompressionStream("deflate");
@ -137,14 +144,16 @@ export function App() {
const refRightSideBar = useRef<HTMLDivElement>(null); const refRightSideBar = useRef<HTMLDivElement>(null);
// parse concrete syntax always: // parse concrete syntax always:
const conns = useMemo(() => detectConnections(editorState), [editorState]); const conns = useMemo(() => editorState && detectConnections(editorState), [editorState]);
const [ast, syntaxErrors] = useMemo(() => parseStatechart(editorState, conns), [editorState, conns]); const parsed = useMemo(() => editorState && conns && parseStatechart(editorState, conns), [editorState, conns]);
const ast = parsed && parsed[0];
const syntaxErrors = parsed && parsed[1];
console.log('render App', ast); console.log('render App', ast);
// append editor state to undo history // append editor state to undo history
const makeCheckPoint = useCallback(() => { const makeCheckPoint = useCallback(() => {
setEditHistory(historyState => ({ setEditHistory(historyState => historyState && ({
...historyState, ...historyState,
history: [...historyState.history, historyState.current], history: [...historyState.history, historyState.current],
future: [], future: [],
@ -152,6 +161,7 @@ export function App() {
}, [setEditHistory]); }, [setEditHistory]);
const onUndo = useCallback(() => { const onUndo = useCallback(() => {
setEditHistory(historyState => { setEditHistory(historyState => {
if (historyState === null) return null;
if (historyState.history.length === 0) { if (historyState.history.length === 0) {
return historyState; // no change return historyState; // no change
} }
@ -164,6 +174,7 @@ export function App() {
}, [setEditHistory]); }, [setEditHistory]);
const onRedo = useCallback(() => { const onRedo = useCallback(() => {
setEditHistory(historyState => { setEditHistory(historyState => {
if (historyState === null) return null;
if (historyState.future.length === 0) { if (historyState.future.length === 0) {
return historyState; // no change return historyState; // no change
} }
@ -186,6 +197,7 @@ export function App() {
}, [refRightSideBar.current]); }, [refRightSideBar.current]);
const onInit = useCallback(() => { const onInit = useCallback(() => {
if (ast === null) return;
const timestampedEvent = {simtime: 0, inputEvent: "<init>"}; const timestampedEvent = {simtime: 0, inputEvent: "<init>"};
let config; let config;
try { try {
@ -213,6 +225,7 @@ export function App() {
// raise input event, producing a new runtime configuration (or a runtime error) // raise input event, producing a new runtime configuration (or a runtime error)
const onRaise = (inputEvent: string, param: any) => { const onRaise = (inputEvent: string, param: any) => {
if (ast === null) return;
if (trace !== null && ast.inputEvents.some(e => e.event === inputEvent)) { if (trace !== null && ast.inputEvents.some(e => e.event === inputEvent)) {
const config = current(trace); const config = current(trace);
if (config.kind === "bigstep") { if (config.kind === "bigstep") {
@ -251,6 +264,7 @@ export function App() {
} }
}, [time, trace]); // <-- todo: is this really efficient? }, [time, trace]); // <-- todo: is this really efficient?
function produceNextConfig(simtime: number, event: RT_Event, config: TraceItem) { function produceNextConfig(simtime: number, event: RT_Event, config: TraceItem) {
if (ast === null) return;
const timedEvent = { const timedEvent = {
simtime, simtime,
inputEvent: event.kind === "timer" ? "<timer>" : event.name, inputEvent: event.kind === "timer" ? "<timer>" : event.name,
@ -373,13 +387,13 @@ export function App() {
flex: '0 0 content', flex: '0 0 content',
}} }}
> >
<TopPanel {editHistory && <TopPanel
{...{trace, time, setTime, onUndo, onRedo, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory}} {...{trace, time, setTime, onUndo, onRedo, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory}}
/> />}
</Box> </Box>
{/* Below the top bar: Editor */} {/* Below the top bar: Editor */}
<Box sx={{flexGrow:1, overflow: "auto"}}> <Box sx={{flexGrow:1, overflow: "auto"}}>
<VisualEditor {...{state: editorState, setState: setEditorState, conns, trace, setTrace, syntaxErrors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}}/> {editorState && conns && syntaxErrors && <VisualEditor {...{state: editorState, setState: setEditorState, conns, trace, setTrace, syntaxErrors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}}/>}
</Box> </Box>
</Stack> </Stack>
</Box> </Box>
@ -401,24 +415,24 @@ export function App() {
<PersistentDetails localStorageKey="showStateTree" initiallyOpen={true}> <PersistentDetails localStorageKey="showStateTree" initiallyOpen={true}>
<summary>state tree</summary> <summary>state tree</summary>
<ul> <ul>
<ShowAST {...{...ast, trace, highlightActive}}/> {ast && <ShowAST {...{...ast, trace, highlightActive}}/>}
</ul> </ul>
</PersistentDetails> </PersistentDetails>
<PersistentDetails localStorageKey="showInputEvents" initiallyOpen={true}> <PersistentDetails localStorageKey="showInputEvents" initiallyOpen={true}>
<summary>input events</summary> <summary>input events</summary>
<ShowInputEvents {ast && <ShowInputEvents
inputEvents={ast.inputEvents} inputEvents={ast.inputEvents}
onRaise={onRaise} onRaise={onRaise}
disabled={trace===null || trace.trace[trace.idx].kind === "error"} disabled={trace===null || trace.trace[trace.idx].kind === "error"}
showKeys={showKeys}/> showKeys={showKeys}/>}
</PersistentDetails> </PersistentDetails>
<PersistentDetails localStorageKey="showInternalEvents" initiallyOpen={true}> <PersistentDetails localStorageKey="showInternalEvents" initiallyOpen={true}>
<summary>internal events</summary> <summary>internal events</summary>
<ShowInternalEvents internalEvents={ast.internalEvents}/> {ast && <ShowInternalEvents internalEvents={ast.internalEvents}/>}
</PersistentDetails> </PersistentDetails>
<PersistentDetails localStorageKey="showOutputEvents" initiallyOpen={true}> <PersistentDetails localStorageKey="showOutputEvents" initiallyOpen={true}>
<summary>output events</summary> <summary>output events</summary>
<ShowOutputEvents outputEvents={ast.outputEvents}/> {ast && <ShowOutputEvents outputEvents={ast.outputEvents}/>}
</PersistentDetails> </PersistentDetails>
<PersistentDetails localStorageKey="showPlant" initiallyOpen={true}> <PersistentDetails localStorageKey="showPlant" initiallyOpen={true}>
<summary>plant</summary> <summary>plant</summary>
@ -445,7 +459,7 @@ export function App() {
{/* <PersistentDetails localStorageKey="showExecutionTrace" initiallyOpen={true}> */} {/* <PersistentDetails localStorageKey="showExecutionTrace" initiallyOpen={true}> */}
{/* <summary>execution trace</summary> */} {/* <summary>execution trace</summary> */}
<div ref={refRightSideBar}> <div ref={refRightSideBar}>
<RTHistory {...{ast, trace, setTrace, setTime}}/> {ast && <RTHistory {...{ast, trace, setTrace, setTime}}/>}
</div> </div>
{/* </PersistentDetails> */} {/* </PersistentDetails> */}
</Box>} </Box>}
@ -460,7 +474,7 @@ export function App() {
{/* Bottom panel */} {/* Bottom panel */}
<Box sx={{flex: '0 0 content'}}> <Box sx={{flex: '0 0 content'}}>
<BottomPanel {...{errors: syntaxErrors}}/> {syntaxErrors && <BottomPanel {...{errors: syntaxErrors}}/>}
</Box> </Box>
</Stack> </Stack>
</>; </>;