diff --git a/package-lock.json b/package-lock.json index efebd73..d508bc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,10 @@ "name": "enact", "version": "0.0.0", "dependencies": { - "effection": "^3.3.0", + "effection": "^3.4.0", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-error-boundary": "^5.0.0" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -264,6 +265,18 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", @@ -1923,9 +1936,9 @@ "license": "MIT" }, "node_modules/effection": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/effection/-/effection-3.3.0.tgz", - "integrity": "sha512-dvU4LIP16zF3F9YOaUib8kzc1wLmC5hQBrKJmLl3WEl/B4J2HcDa1dguIxuBw5UYxJC5u01jieNGyyffRhwWCw==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/effection/-/effection-3.4.0.tgz", + "integrity": "sha512-QqANcLVEBzKoU9CswdWBfk01i23GZCktsNEcnBSSXZgvYc+1AekHpmU/lras/GJALW36Yp7+FdvcMR3GG1CvKg==", "license": "ISC", "engines": { "node": ">= 16" @@ -2845,6 +2858,18 @@ "react": "^19.1.0" } }, + "node_modules/react-error-boundary": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-5.0.0.tgz", + "integrity": "sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -2855,6 +2880,12 @@ "node": ">=0.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/package.json b/package.json index b2e6e12..be5b2c8 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,10 @@ "preview": "vite preview" }, "dependencies": { - "effection": "^3.3.0", + "effection": "^3.4.0", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-error-boundary": "^5.0.0" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/src/App.css b/src/App.css index b9d355d..0325ce2 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } diff --git a/src/App.tsx b/src/App.tsx index e037b89..c7acba6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,4 +32,4 @@ export const App = enact(function* () { ); -}); \ No newline at end of file +}); diff --git a/src/enact.tsx b/src/enact.tsx index e5d2653..37d00cd 100644 --- a/src/enact.tsx +++ b/src/enact.tsx @@ -6,56 +6,70 @@ 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 { + // NOTE: Disambiguate between undefined ReactNode's and yielding void. (props: T): Operation; } -export interface ReactComponent { - (props: T): ReactNode; -} - -export function* render(node: ReactNode): Operation { +export function* render(current?: ReactNode): Operation { 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(component: EnactComponent): ReactComponent { +export function enact(component: EnactComponent) { return (props: T) => { - let [content, setContent] = useState(null); + const [content, setContent] = useState(); + // Store ref to Future of previous render to block subsequent renders until + // cleanup function has run to completion. + const destroying = useRef>(void 0); + let [err, setErr] = useState<{error: unknown} | void>(); 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* () { + try { + // Block subsequent renders until cleanup function has run to completion. + if (destroying.current) { + yield* destroying.current; + } + const val = yield* component(props); + if (React.isValidElement(val)) { + setContent(val); + } + } catch (error) { + setErr({ error }); } - } catch (e) { - let error = e as Error; - setContent( - <> -

Component Crash

-

{error?.message}

-
{error?.stack}
- , - ); - } - }); - return () => { destroy() }; - }, []); + }) + return () => { + destroying.current = destroy(); + destroying.current + // .then(() => { throw new Error('Caught on cleanup') }) + .catch((error) => { + console.error(`uncaught error during teardown`, error) + }); + }; + }, [props]); + + // this is the key weird looking part + useEffect(() => { + if (err?.error) { + throw err.error; + } + }, [err]); return content; }; @@ -115,7 +129,7 @@ export function useValue(initial: T): Value { } export interface Computed extends Stream { - react: ReactComponent>; + react: React.FC>; } export function compute( @@ -131,7 +145,7 @@ export function compute( let react = enact>(function* () { for (let value of yield* each(computed)) { - yield* $(String(value)); + yield* r(String(value)); yield* each.next(); } }); @@ -151,8 +165,8 @@ export function map( 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) }; }, }; }, diff --git a/src/examples/Counter.tsx b/src/examples/Counter.tsx index da57bdc..48a0d52 100644 --- a/src/examples/Counter.tsx +++ b/src/examples/Counter.tsx @@ -1,4 +1,4 @@ -import { enact, useValue } from "../enact.tsx"; +import { enact, r, useValue } from "../enact.tsx"; /** * ```ts @@ -13,12 +13,13 @@ import { enact, useValue } from "../enact.tsx"; } ``` */ -export const Counter = enact(function*() { - let count = useValue(0); - - return ( - - ); -}) \ No newline at end of file + ); +}); + diff --git a/src/examples/Search.tsx b/src/examples/Search.tsx index eed74e9..a949c5c 100644 --- a/src/examples/Search.tsx +++ b/src/examples/Search.tsx @@ -1,62 +1,98 @@ -import { - each, - spawn, - sleep, - useAbortSignal, - call, - Task, -} from "effection"; -import { enact, $, useValue, Value } from "../enact.tsx"; -import { ChangeEventHandler } from "react"; +import { useAbortSignal, action, until, type Operation } from "effection"; +import { enact, r } from "../enact.tsx"; +import React, { ChangeEventHandler } from "react"; +import { ErrorBoundary } from "react-error-boundary"; -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 = (event) => { - query.set(event.target.value); - }; + const [throwOn, setThrowOn] = React.useState<"sync" | "async" | undefined>(); + const isThrower = (s: string): s is "sync" | "async" => + ["sync", "async"].includes(s) return (
- - + +
+ +
+ `Oh no! ${error}`} + > + +
); -}); +} -const SearchResults = enact<{ query: Value }>(function* (props) { - let lastTask: Task | undefined; +let results: Results | undefined; - for (const q of yield* each(props.query)) { - if (!q?.length) { - yield* $(

Enter a keyword to search for packages on NPM.

); // Renders an "Initial State" - yield* each.next(); - continue; // skip everything else below. +const SearchResults = enact<{ + throwOn?: "async" | "sync"; + query?: string; +}>(function* ({ query, throwOn }) { + if (!query?.length) { + results = undefined; + return

Enter a keyword to search for packages on NPM.

; // Renders an "Initial State" + } + if (results) { + yield* r( +
+ +
+
, + ); + } else { + yield* r(

Loading results for {query}...

); + } + switch (throwOn) { + case "async": { + yield* action((_res, rej) => { + let to = setTimeout(() => rej(new Error("Ah Dang!")), 1000); + return () => clearTimeout(to); + }); + break; } - - if (lastTask) { - yield* lastTask.halt(); + case "sync": { + throw new Error("Oh Bummer!"); } - - lastTask = yield* spawn(function* () { - yield* $(

Loading results for {q}...

); - // 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* $(); - } catch (error) { - yield* $(); - } - }); - - yield* each.next(); + } + try { + const stuff = yield* npmSearch(query); + results = stuff; + return ; + } catch (error) { + return ; } }); -function SearchResultsList({ results }: { results: unknown[] }) { +function SearchResultsList({ results }: Results) { return results.length === 0 ? (

No results

) : ( @@ -78,19 +114,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 { 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 }) { diff --git a/src/examples/Stopwatch.tsx b/src/examples/Stopwatch.tsx index 2d6e795..fed95f1 100644 --- a/src/examples/Stopwatch.tsx +++ b/src/examples/Stopwatch.tsx @@ -1,4 +1,4 @@ -import { $, enact, map, useValue } from "../enact.tsx"; +import { r, enact, map, useValue } from "../enact.tsx"; import { interval } from "../operations/interval.ts"; import { each, Operation, race, spawn } from "effection"; @@ -66,7 +66,7 @@ export const StopWatch = enact(function* () { }); for (let isRunning of yield* each(running)) { - yield* $( + yield* r( <>

Time passed: