diff --git a/package-lock.json b/package-lock.json index efebd73..982abc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "enact", "version": "0.0.0", "dependencies": { - "effection": "^3.3.0", + "effection": "^3.4.0", "react": "^19.1.0", "react-dom": "^19.1.0" }, @@ -1923,9 +1923,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" diff --git a/package.json b/package.json index b2e6e12..bd0611e 100644 --- a/package.json +++ b/package.json @@ -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" }, 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..8d01a4f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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* $( <>
@@ -32,4 +33,4 @@ export const App = enact(function* () {
); -}); \ No newline at end of file +}); diff --git a/src/enact.tsx b/src/enact.tsx index e5d2653..4badc8a 100644 --- a/src/enact.tsx +++ b/src/enact.tsx @@ -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 { + // 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); 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( - <> -

Component Crash

-

{error?.message}

-
{error?.stack}
- , - ); - } - }); - 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); + }); + }; + }, [props]); return content; }; @@ -115,7 +119,7 @@ export function useValue(initial: T): Value { } export interface Computed extends Stream { - react: ReactComponent>; + react: React.FC>; } export function compute( @@ -131,7 +135,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 +155,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..9f8773d 100644 --- a/src/examples/Counter.tsx +++ b/src/examples/Counter.tsx @@ -1,4 +1,4 @@ -import { enact, useValue } from "../enact.tsx"; +import { enact, $, 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..714f429 100644 --- a/src/examples/Search.tsx +++ b/src/examples/Search.tsx @@ -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 = (event) => { - query.set(event.target.value); + setQuery(event.target.value); }; return (
- - + +
); -}); - -const SearchResults = enact<{ query: Value }>(function* (props) { - let lastTask: Task | 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. - } - - if (lastTask) { - yield* lastTask.halt(); - } - - 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* $(); - } - }); +let results: Results | undefined; - yield* each.next(); +const SearchResults = enact<{ query?: string }>(function* ({ query }) { + 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}...

); + } + 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 +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 { 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 }) {