Skip to content

Commit 95772ca

Browse files
committed
Add support for client-side routing
1 parent fff7c5e commit 95772ca

File tree

5 files changed

+295
-168
lines changed

5 files changed

+295
-168
lines changed

src/Experiment.tsx

Lines changed: 7 additions & 167 deletions
Original file line numberDiff line numberDiff line change
@@ -1,171 +1,11 @@
1-
import { createContext, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
2-
import { genUserIdDefault } from './utils.js';
3-
import { Result, db } from './database.js';
4-
import { DefaultEndScreen, renderDefaultErrorScreen } from './defaults.js';
5-
import { LoginOptions } from './login.js';
6-
import { createRoot } from 'react-dom/client';
7-
8-
let errorHandlerEffectRun = false;
9-
10-
interface ExperimentInternals {
11-
currentTask: string;
12-
registerTask: (id: string) => void;
13-
unregisterTask: (id: string) => void;
14-
advance: () => void;
15-
addResult: (taskId: string, screenId: string, key: string, val: string) => void;
16-
}
17-
18-
interface ExperimentControls {
19-
login: (userId: string) => void;
20-
}
21-
22-
const ExperimentInternalsDefault: ExperimentInternals = {
23-
currentTask: '',
24-
registerTask: () => { throw new Error('Experiment ancestor component not found.'); },
25-
unregisterTask: () => { throw new Error('Experiment ancestor component not found.'); },
26-
advance: () => { throw new Error('Experiment ancestor component not found.'); },
27-
addResult: () => { throw new Error('Experiment ancestor component not found.'); },
28-
};
29-
30-
const ExperimentControlsDefault: ExperimentControls = {
31-
login: () => { throw new Error('Experiment ancestor component not found.'); },
32-
};
33-
34-
const ExperimentInternalsContext = createContext(ExperimentInternalsDefault);
35-
const ExperimentControlsContext = createContext(ExperimentControlsDefault);
36-
37-
type ExperimentProps = {
38-
loginOptions?: LoginOptions;
39-
onResultAdded?: (result: Result) => void;
40-
endScreen?: React.ReactNode;
41-
useErrorHandling?: boolean;
42-
renderErrorScreen?: (event: ErrorEvent | PromiseRejectionEvent) => React.ReactNode;
43-
children: React.ReactNode;
44-
};
45-
46-
function Experiment({
47-
loginOptions = { loginType: 'skip', loginComponent: <> </> },
48-
endScreen = <DefaultEndScreen />,
49-
useErrorHandling = false,
50-
renderErrorScreen = renderDefaultErrorScreen,
51-
...otherProps
52-
}: ExperimentProps) {
53-
// valid user ID must not be empty
54-
const [userId, setUserId] = useState('');
55-
const [ended, setEnded] = useState(false);
56-
const allTasksRef = useRef<string[]>([]);
57-
const taskRef = useRef('');
58-
const [, forceUpdate] = useReducer(x => x + 1, 0);
59-
60-
useEffect(() => {
61-
if (loginOptions.loginType == 'skip' && userId == '') {
62-
genUserIdDefault()
63-
.then((id) => {
64-
login(id);
65-
});
66-
}
67-
if (useErrorHandling && !errorHandlerEffectRun) {
68-
errorHandlerEffectRun = true;
69-
window.addEventListener('error', errorListener);
70-
window.addEventListener('unhandledrejection', errorListener);
71-
}
72-
}, []);
73-
74-
function errorListener(event: ErrorEvent | PromiseRejectionEvent) {
75-
document.body.innerHTML = '';
76-
const newDiv = document.createElement('div');
77-
const root = createRoot(newDiv);
78-
root.render(
79-
renderErrorScreen(event),
80-
);
81-
document.body.appendChild(newDiv);
82-
}
83-
84-
const registerTask = useCallback((id: string) => {
85-
// no duplicate IDs allowed
86-
if (allTasksRef.current.includes(id)) {
87-
throw new Error(`Task ID '${id}' already exists.`);
88-
}
89-
console.log(`Task registered with ID ${id}.`);
90-
const newTasks = [...allTasksRef.current, id];
91-
allTasksRef.current = newTasks;
92-
// if this is the first task, select it
93-
if (newTasks.length == 1) {
94-
updateCurrentTask(id);
95-
}
96-
}, [taskRef, allTasksRef]);
97-
98-
const unregisterTask = useCallback((id: string) => {
99-
// do nothing if ID is not found
100-
if (allTasksRef.current.includes(id)) {
101-
console.log(`Task unregistered with ID ${id}.`);
102-
const newTasks = allTasksRef.current.filter(task => task != id);
103-
allTasksRef.current = newTasks;
104-
if (newTasks.length == 0) {
105-
updateCurrentTask('');
106-
}
107-
}
108-
}, [taskRef, allTasksRef]);
109-
110-
function updateCurrentTask(id: string) {
111-
taskRef.current = id;
112-
forceUpdate();
113-
};
114-
115-
const advance = useCallback(() => {
116-
const curIndex = allTasksRef.current.indexOf(taskRef.current);
117-
if (curIndex < allTasksRef.current.length - 1) {
118-
updateCurrentTask(allTasksRef.current[curIndex + 1]);
119-
}
120-
else {
121-
setEnded(true);
122-
}
123-
}, [taskRef, allTasksRef]);
124-
125-
const addResult = useCallback(async (taskId: string, screenId: string, key: string, val: string) => {
126-
const result: Result = { taskId, screenId, userId, key, val };
127-
await db.results.add(result);
128-
// TODO: Do I need error handling?
129-
130-
if (otherProps.onResultAdded) {
131-
otherProps.onResultAdded(result);
132-
}
133-
}, [userId, otherProps.onResultAdded]);
134-
135-
const experimentInternals = useMemo(() => ({
136-
currentTask: taskRef.current,
137-
registerTask,
138-
unregisterTask,
139-
advance,
140-
addResult,
141-
}), [taskRef.current, registerTask, unregisterTask, advance, addResult]);
142-
143-
const login = useCallback((userId: string) => {
144-
setUserId(userId);
145-
}, [setUserId]);
146-
147-
const experimentControls = useMemo(() => ({
148-
login,
149-
}), [login]);
150-
151-
let toDisplay;
152-
if (ended) {
153-
toDisplay = endScreen;
154-
}
155-
else if (userId == '') {
156-
toDisplay = loginOptions.loginComponent;
157-
}
158-
else {
159-
toDisplay = otherProps.children;
160-
}
1+
import { ExperimentCore, ExperimentProps } from './core.js';
1612

3+
function Experiment(props: ExperimentProps) {
1624
return (
163-
<ExperimentInternalsContext.Provider value={experimentInternals}>
164-
<ExperimentControlsContext.Provider value={experimentControls}>
165-
{toDisplay}
166-
</ExperimentControlsContext.Provider>
167-
</ExperimentInternalsContext.Provider>
5+
<ExperimentCore dynamic={false} {...props}>
6+
{props.children}
7+
</ExperimentCore>
1688
);
169-
};
9+
}
17010

171-
export { ExperimentInternalsContext, ExperimentControlsContext, ExperimentProps, Experiment };
11+
export { Experiment };

src/ExperimentDynamic.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Dynamic, ExperimentCore, ExperimentProps } from './core.js';
2+
3+
/**
4+
* Dynamic version of `<Experiment>`.
5+
*
6+
* By default, `<Experiment>` expects to know all the `<Task>`s ahead of time:
7+
*
8+
* ```tsx
9+
* function MyExperiment() {
10+
* return (
11+
* <Experiment>
12+
* <MyFirstTask />
13+
* <MySecondTask />
14+
* ...
15+
* </Experiment>
16+
* );
17+
* }
18+
* ```
19+
*
20+
* However, this means that the tasks are all bundled together and loaded at once, which may
21+
* not be ideal for large experiments. If you are using a framework like Next.js, you can
22+
* instead use <ExperimentDynamic>, which expects a predefined task list and a routing function.
23+
* The below example uses Next.js App Router:
24+
*
25+
* ```tsx
26+
* // since MyExperiment uses the useRouter hook, it must be a Client Component and thus must
27+
* // be defined in a separate file from the root layout
28+
* import { useRouter } from 'next/navigation';
29+
*
30+
* export function MyExperiment({ children }: { children: React.ReactNode }) {
31+
* const router = useRouter();
32+
*
33+
* return (
34+
* <ExperimentDynamic taskList={['first-task', 'second-task']} onNextTask={(taskId) => {
35+
* router.push(taskId);
36+
* }}>
37+
* {children}
38+
* </ExperimentDynamic>
39+
* );
40+
* }
41+
*
42+
* // in app/layout.tsx
43+
* export default function RootLayout({ children }: { children: React.ReactNode }) {
44+
* return (
45+
* <MyExperiment>
46+
* {children}
47+
* </MyExperiment>
48+
* );
49+
* }
50+
* ```
51+
*
52+
* With a <ExperimentDynamic> component, you do not have to manually declare `<Task>` components.
53+
* <ExperimentDynamic> will automatically wrap the children in a `<Task>` component with the current
54+
* taskId, as defined in `taskList`.
55+
*/
56+
function ExperimentDynamic(props: ExperimentProps & Dynamic) {
57+
return (
58+
<ExperimentCore dynamic={true} {...props}>
59+
{props.children}
60+
</ExperimentCore>
61+
);
62+
}
63+
64+
export { ExperimentDynamic };

src/Task.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { createContext, useCallback, useMemo, useContext, useEffect, useRef, useReducer } from 'react';
2-
import { ExperimentInternalsContext } from './Experiment.js';
2+
import { ExperimentInternalsContext } from './core.js';
33

44
interface TaskInternals {
55
currentScreen: string;

0 commit comments

Comments
 (0)