From 0a52f2a6d6703a4c394a8795a1b6eca1421e24b0 Mon Sep 17 00:00:00 2001 From: Austin Erlandson Date: Sat, 12 Apr 2025 20:10:34 -0500 Subject: [PATCH 1/4] Remove need for wrapping individual Prop's as Values and re-rendering replaces the need for a `for` loop --- src/App.css | 9 ++++ src/App.tsx | 5 ++- src/enact.tsx | 63 +++++++++++++-------------- src/examples/Search.tsx | 95 ++++++++++++++++++++++++----------------- 4 files changed, 97 insertions(+), 75 deletions(-) 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..1e9c503 100644 --- a/src/enact.tsx +++ b/src/enact.tsx @@ -11,53 +11,50 @@ import { Stream, } from "effection"; import type { ReactNode } from "react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; export interface EnactComponent { - (props: T): Operation; + // NOTE: Disambiguate between undefined ReactNode's and yielding void. + (props: T): Operation<{ current: ReactNode } | void>; } -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; -const RenderContext = createContext<(node: ReactNode) => void>("enact.render"); +const RenderContext = + createContext<(_: { current: 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<{ current: ReactNode }>({ + current: null, + }); + // Create the scope instance for use during the component lifecycle. + const [scope, destroy] = useMemo(() => { + const [scope, destroy] = createScope(); + scope.set(RenderContext, setContent); + return [scope, destroy]; + }, []); + // Avoid forcing users to wrap all the individual props they care about in Value's. useEffect(() => { - let [scope, destroy] = createScope(); - scope.set(RenderContext, setContent); scope.run(function* () { - try { - let result = yield* component(props); - if (result) { - setContent(result); - } - } catch (e) { - let error = e as Error; - setContent( - <> -

Component Crash

-

{error?.message}

-
{error?.stack}
- , - ); + const val = yield* component(props); + if (val !== undefined) { + setContent(val); } }); - return () => { destroy() }; - }, []); + return () => { + // console.log("Destroyed"); + destroy(); + }; + }, [props]); - return content; + return content.current; }; } @@ -115,7 +112,7 @@ export function useValue(initial: T): Value { } export interface Computed extends Stream { - react: ReactComponent>; + react: React.FC>; } export function compute( @@ -151,8 +148,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/Search.tsx b/src/examples/Search.tsx index eed74e9..ffd195c 100644 --- a/src/examples/Search.tsx +++ b/src/examples/Search.tsx @@ -1,62 +1,66 @@ import { - each, spawn, - sleep, useAbortSignal, call, Task, + suspend, + type Operation, } from "effection"; -import { enact, $, useValue, Value } from "../enact.tsx"; -import { ChangeEventHandler } from "react"; +import { enact, $ } 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 task: Task | undefined; +let results: Results | undefined; - yield* each.next(); +const SearchResults = enact<{ query: string | undefined }>(function* ({ + query, +}) { + if (task) { + yield* task.halt(); + } + if (!query?.length) { + yield* $(

Enter a keyword to search for packages on NPM.

); // Renders an "Initial State" + results = undefined; + return; // skip everything else below. } + if (results) { + yield* $( +
+ +
+
, + ); + } else { + yield* $(

Loading results for {query}...

); + } + task = yield* spawn(function* () { + try { + const stuff = yield* npmSearch(query); + results = stuff; + yield* $(); + } catch (error) { + yield* $(); + } + }); + yield* suspend(); }); -function SearchResultsList({ results }: { results: unknown[] }) { +function SearchResultsList({ results }: Results) { return results.length === 0 ? (

No results

) : ( @@ -78,7 +82,18 @@ 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}`; From adeffbedecc19d581471a86b387b8fb18c9a0c1c Mon Sep 17 00:00:00 2001 From: Austin Erlandson Date: Mon, 14 Apr 2025 17:01:13 -0500 Subject: [PATCH 2/4] Post-pair programming with @cowboyd --- package-lock.json | 8 ++++---- package.json | 2 +- src/enact.tsx | 25 +++++++++++++++---------- src/examples/Search.tsx | 40 ++++++++++++---------------------------- 4 files changed, 32 insertions(+), 43 deletions(-) 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/enact.tsx b/src/enact.tsx index 1e9c503..4b4a8c4 100644 --- a/src/enact.tsx +++ b/src/enact.tsx @@ -6,12 +6,13 @@ import { createSignal, each, type Operation, + type Future, resource, spawn, Stream, } from "effection"; import type { ReactNode } from "react"; -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState, useRef } from "react"; export interface EnactComponent { // NOTE: Disambiguate between undefined ReactNode's and yielding void. @@ -33,24 +34,28 @@ export function enact(component: EnactComponent) { const [content, setContent] = useState<{ current: ReactNode }>({ current: null, }); - // Create the scope instance for use during the component lifecycle. - const [scope, destroy] = useMemo(() => { - const [scope, destroy] = createScope(); - scope.set(RenderContext, setContent); - return [scope, destroy]; - }, []); + // Store ref to Future of previous render to block subsequent renders until + // cleanup function has run to completion. + const destroying = useRef>(void 0); - // Avoid forcing users to wrap all the individual props they care about in Value's. useEffect(() => { + const [scope, destroy] = createScope(); + scope.set(RenderContext, setContent); scope.run(function* () { + // Block subsequent renders until cleanup function has run to completion. + if (destroying.current) { + yield* destroying.current; + } const val = yield* component(props); if (val !== undefined) { setContent(val); } }); return () => { - // console.log("Destroyed"); - destroy(); + destroying.current = destroy(); + destroying.current.catch((e) => { + console.error("Cleanup failed:", e); + }); }; }, [props]); diff --git a/src/examples/Search.tsx b/src/examples/Search.tsx index ffd195c..2f04cdb 100644 --- a/src/examples/Search.tsx +++ b/src/examples/Search.tsx @@ -1,11 +1,4 @@ -import { - spawn, - useAbortSignal, - call, - Task, - suspend, - type Operation, -} from "effection"; +import { useAbortSignal, until, type Operation } from "effection"; import { enact, $ } from "../enact.tsx"; import React, { ChangeEventHandler } from "react"; @@ -24,15 +17,9 @@ export function Search(props: { query?: string }) { ); } -let task: Task | undefined; let results: Results | undefined; -const SearchResults = enact<{ query: string | undefined }>(function* ({ - query, -}) { - if (task) { - yield* task.halt(); - } +const SearchResults = enact<{ query?: string }>(function* ({ query }) { if (!query?.length) { yield* $(

Enter a keyword to search for packages on NPM.

); // Renders an "Initial State" results = undefined; @@ -48,16 +35,13 @@ const SearchResults = enact<{ query: string | undefined }>(function* ({ } else { yield* $(

Loading results for {query}...

); } - task = yield* spawn(function* () { - try { - const stuff = yield* npmSearch(query); - results = stuff; - yield* $(); - } catch (error) { - yield* $(); - } - }); - yield* suspend(); + try { + const stuff = yield* npmSearch(query); + results = stuff; + yield* $(); + } catch (error) { + yield* $(); + } }); function SearchResultsList({ results }: Results) { @@ -97,15 +81,15 @@ 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 }) { From 79fcc3e474dccacca1338c771dd30abaf7554b5d Mon Sep 17 00:00:00 2001 From: Austin Erlandson Date: Tue, 15 Apr 2025 11:47:31 -0500 Subject: [PATCH 3/4] Properly bubble up async errors from the enact scope.run call into the React component. --- src/enact.tsx | 24 ++++++++++++++---------- src/examples/Counter.tsx | 19 ++++++++++--------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/enact.tsx b/src/enact.tsx index 4b4a8c4..719b985 100644 --- a/src/enact.tsx +++ b/src/enact.tsx @@ -41,16 +41,20 @@ export function enact(component: EnactComponent) { useEffect(() => { const [scope, destroy] = createScope(); scope.set(RenderContext, setContent); - scope.run(function* () { - // Block subsequent renders until cleanup function has run to completion. - if (destroying.current) { - yield* destroying.current; - } - const val = yield* component(props); - if (val !== undefined) { - setContent(val); - } - }); + scope + .run(function* () { + // Block subsequent renders until cleanup function has run to completion. + if (destroying.current) { + yield* destroying.current; + } + const val = yield* component(props); + if (val !== undefined) { + setContent(val); + } + }) + .catch((e) => { + throw new Error(e); + }); return () => { destroying.current = destroy(); destroying.current.catch((e) => { 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 + , + ); +}); + From 52355559f545db018ae4948a0b62f7a386af6c47 Mon Sep 17 00:00:00 2001 From: Austin Erlandson Date: Tue, 15 Apr 2025 15:51:23 -0500 Subject: [PATCH 4/4] Make return behave correctly again. --- src/enact.tsx | 22 ++++++++++------------ src/examples/Search.tsx | 13 ++++++------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/enact.tsx b/src/enact.tsx index 719b985..4badc8a 100644 --- a/src/enact.tsx +++ b/src/enact.tsx @@ -12,28 +12,26 @@ import { Stream, } from "effection"; import type { ReactNode } from "react"; -import { useEffect, useState, useRef } from "react"; +import React, { useEffect, useState, useRef } from "react"; export interface EnactComponent { // NOTE: Disambiguate between undefined ReactNode's and yielding void. - (props: T): Operation<{ current: ReactNode } | void>; + (props: T): Operation; } -export function* render(current: ReactNode): Operation { +export function* render(current?: ReactNode): Operation { let setContent = yield* RenderContext.expect(); - setContent({ current }); + setContent(current); } -export const $ = render; +export const r = render; const RenderContext = - createContext<(_: { current: ReactNode }) => void>("enact.render"); + createContext<(_: ReactNode) => void>("enact.render"); export function enact(component: EnactComponent) { return (props: T) => { - const [content, setContent] = useState<{ current: ReactNode }>({ - current: 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); @@ -48,7 +46,7 @@ export function enact(component: EnactComponent) { yield* destroying.current; } const val = yield* component(props); - if (val !== undefined) { + if (React.isValidElement(val)) { setContent(val); } }) @@ -63,7 +61,7 @@ export function enact(component: EnactComponent) { }; }, [props]); - return content.current; + return content; }; } @@ -137,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(); } }); diff --git a/src/examples/Search.tsx b/src/examples/Search.tsx index 2f04cdb..714f429 100644 --- a/src/examples/Search.tsx +++ b/src/examples/Search.tsx @@ -1,5 +1,5 @@ import { useAbortSignal, until, type Operation } from "effection"; -import { enact, $ } from "../enact.tsx"; +import { enact, r } from "../enact.tsx"; import React, { ChangeEventHandler } from "react"; export function Search(props: { query?: string }) { @@ -21,26 +21,25 @@ let results: Results | undefined; const SearchResults = enact<{ query?: string }>(function* ({ query }) { if (!query?.length) { - yield* $(

Enter a keyword to search for packages on NPM.

); // Renders an "Initial State" results = undefined; - return; // skip everything else below. + return

Enter a keyword to search for packages on NPM.

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

Loading results for {query}...

); + yield* r(

Loading results for {query}...

); } try { const stuff = yield* npmSearch(query); results = stuff; - yield* $(); + return ; } catch (error) { - yield* $(); + return ; } });