Skip to content
Open
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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"effection": "^3.3.0",
"effection": "^3.4.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
Expand Down
9 changes: 9 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@
padding: 2em;
}

/* I'm too used to Tailwind :laughing: */
.relative {position: relative;}
.absolute {position: absolute;}
.opacity-50 {opacity: 0.5;}
.bg-black {background-color: black;}
.top-0 {top: 0;}
.left-0 {left: 0;}
.size-full {height: 100%; width: 100%}

.read-the-docs {
color: #888;
}
5 changes: 3 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { enact } from "./enact.tsx";
import { StopWatch } from "./examples/Stopwatch.tsx";
import { Counter } from "./examples/Counter.tsx";
import { Search } from "./examples/Search.tsx";
import { $ } from './enact.tsx'

export const App = enact(function* () {
return (
yield* $(
<>
<div>
<a href="https://frontside.com/effection" target="_blank">
Expand All @@ -32,4 +33,4 @@ export const App = enact(function* () {
</div>
</>
);
});
});
72 changes: 38 additions & 34 deletions src/enact.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,56 +6,60 @@ import {
createSignal,
each,
type Operation,
type Future,
resource,
spawn,
Stream,
} from "effection";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import React, { useEffect, useState, useRef } from "react";

export interface EnactComponent<T> {
// NOTE: Disambiguate between undefined ReactNode's and yielding void.
(props: T): Operation<ReactNode | void>;
}

export interface ReactComponent<T> {
(props: T): ReactNode;
}

export function* render(node: ReactNode): Operation<void> {
export function* render(current?: ReactNode): Operation<void> {
let setContent = yield* RenderContext.expect();
setContent(node);
setContent(current);
}

export const $ = render;
export const r = render;

const RenderContext = createContext<(node: ReactNode) => void>("enact.render");
const RenderContext =
createContext<(_: ReactNode) => void>("enact.render");

export function enact<T>(component: EnactComponent<T>): ReactComponent<T> {
export function enact<T>(component: EnactComponent<T>) {
return (props: T) => {
let [content, setContent] = useState<ReactNode>(null);
const [content, setContent] = useState<ReactNode>();
// Store ref to Future of previous render to block subsequent renders until
// cleanup function has run to completion.
const destroying = useRef<Future<void>>(void 0);

useEffect(() => {
let [scope, destroy] = createScope();
const [scope, destroy] = createScope();
scope.set(RenderContext, setContent);
scope.run(function* () {
try {
let result = yield* component(props);
if (result) {
setContent(result);
scope
.run(function* () {
// Block subsequent renders until cleanup function has run to completion.
if (destroying.current) {
yield* destroying.current;
}
} catch (e) {
let error = e as Error;
setContent(
<>
<h1>Component Crash</h1>
<h3>{error?.message}</h3>
<pre>{error?.stack}</pre>
</>,
);
}
});
return () => { destroy() };
}, []);
const val = yield* component(props);
if (React.isValidElement(val)) {
setContent(val);
}
})
.catch((e) => {
throw new Error(e);
});
return () => {
destroying.current = destroy();
destroying.current.catch((e) => {
console.error("Cleanup failed:", e);
Copy link
Author

Choose a reason for hiding this comment

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

Should this get re-thrown too 🤔

Copy link
Collaborator

Choose a reason for hiding this comment

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

Why should next render block on previous render clean-up. What would happen between that?

For example, a user intends to type "effection" in a search box but they're "effect" through, should the UI display the result for "effect" and wait until the clean-up for that finishes? Which will give the perception of a frozen app

Copy link
Author

Choose a reason for hiding this comment

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

Why should next render block on previous render clean-up.
@cowboyd is better equipped to answer this that was his expertise...
I think he said that there could be races between renders if subsequent renders didn't wait for cleanups?

Definitely confused my intuitions but those aren't particularly oriented around the effection interface just yet so I'm suspending my own disbelief for now.

});
};
}, [props]);

return content;
};
Expand Down Expand Up @@ -115,7 +119,7 @@ export function useValue<T>(initial: T): Value<T> {
}

export interface Computed<T> extends Stream<T, never> {
react: ReactComponent<Record<string | symbol, never>>;
react: React.FC<Record<string | symbol, never>>;
}

export function compute<T>(
Expand All @@ -131,7 +135,7 @@ export function compute<T>(

let react = enact<Record<string, never>>(function* () {
for (let value of yield* each(computed)) {
yield* $(String(value));
yield* r(String(value));
yield* each.next();
}
});
Expand All @@ -151,8 +155,8 @@ export function map<A, B, C>(
let source = yield* stream;
return {
*next() {
let next = yield* source.next();
return next.done ? next : ({ done: false, value: fn(next.value) });
let next = yield* source.next();
return next.done ? next : { done: false, value: fn(next.value) };
},
};
},
Expand Down
19 changes: 10 additions & 9 deletions src/examples/Counter.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { enact, useValue } from "../enact.tsx";
import { enact, $, useValue } from "../enact.tsx";

/**
* ```ts
Expand All @@ -13,12 +13,13 @@ import { enact, useValue } from "../enact.tsx";
}
```
*/
export const Counter = enact(function*() {
let count = useValue(0);
return (
<button type="button" onClick={() => count.set(count.current + 1)}>
export const Counter = enact(function* () {
let count = useValue(0);

yield* $(
<button type="button" onClick={() => count.set(count.current + 1)}>
count is <count.react />
</button>
);
})
</button>,
);
});

96 changes: 47 additions & 49 deletions src/examples/Search.tsx
Original file line number Diff line number Diff line change
@@ -1,62 +1,49 @@
import {
each,
spawn,
sleep,
useAbortSignal,
call,
Task,
} from "effection";
import { enact, $, useValue, Value } from "../enact.tsx";
import { ChangeEventHandler } from "react";
import { useAbortSignal, until, type Operation } from "effection";
import { enact, r } from "../enact.tsx";
import React, { ChangeEventHandler } from "react";

export const Search = enact<{ query: string | undefined }>(function* (props) {
const query = useValue(props.query);
export function Search(props: { query?: string }) {
const [query, setQuery] = React.useState(props.query);

const onChange: ChangeEventHandler<HTMLInputElement> = (event) => {
query.set(event.target.value);
setQuery(event.target.value);
};

return (
<div>
<input value={query.current} onChange={onChange} />
<SearchResults query={query} />
<input value={query} onChange={onChange} />
<SearchResults {...{ query }} />
</div>
);
});

const SearchResults = enact<{ query: Value<string | undefined> }>(function* (props) {
let lastTask: Task<void> | undefined;

for (const q of yield* each(props.query)) {
if (!q?.length) {
yield* $(<p>Enter a keyword to search for packages on NPM.</p>); // Renders an "Initial State"
yield* each.next();
continue; // skip everything else below.
}

if (lastTask) {
yield* lastTask.halt();
}

lastTask = yield* spawn(function* () {
yield* $(<p>Loading results for {q}...</p>);
// Attempting to add debouncing for when things go out of scope below but didn't seem to work :thinking:

yield* sleep(300);
}

try {
let { results } = yield* npmSearch(q);
yield* $(<SearchResultsList results={results} />);
} catch (error) {
yield* $(<ErrorMessage error={error} />);
}
});
let results: Results | undefined;

yield* each.next();
const SearchResults = enact<{ query?: string }>(function* ({ query }) {
if (!query?.length) {
results = undefined;
return <p>Enter a keyword to search for packages on NPM.</p>; // Renders an "Initial State"
}
if (results) {
yield* r(
<div className="relative">
<SearchResultsList {...results} />
<div className="absolute opacity-50 bg-black top-0 left-0 size-full" />
</div>,
);
} else {
yield* r(<p>Loading results for {query}...</p>);
}
try {
const stuff = yield* npmSearch(query);
results = stuff;
return <SearchResultsList {...stuff} />;
} catch (error) {
return <ErrorMessage error={error as Error} />;
}
});

function SearchResultsList({ results }: { results: unknown[] }) {
function SearchResultsList({ results }: Results) {
return results.length === 0 ? (
<p>No results</p>
) : (
Expand All @@ -78,19 +65,30 @@ function SearchResultsList({ results }: { results: unknown[] }) {
);
}

function* npmSearch(query: string) {
type Results = {
results: Array<{
package: {
name: string;
version: string;
description: string;
links: { npm: string };
};
}>;
};

function* npmSearch(query: string): Operation<Results> {
const signal = yield* useAbortSignal();
/* npms.io search API is used in this example. Good stuff.*/
const url = `https://api.npms.io/v2/search?from=0&size=25&q=${query}`;
let response = yield* call(() => fetch(url, { signal }));
let response = yield* until(fetch(url, { signal }));

if (response.ok) {
return yield* call(() => response.json());
return yield* until(response.json());
}

/* If API returns some weird stuff and not 2xx, convert it to error and show
on the screen. */
throw new Error(yield* call(() => response.text()));
throw new Error(yield* until(response.text()));
}

function ErrorMessage({ error }: { error: Error }) {
Expand Down