Skip to content

Commit

Permalink
Merge branch 'next'
Browse files Browse the repository at this point in the history
  • Loading branch information
soxtoby committed Jul 15, 2023
2 parents d79d603 + 1235510 commit ff8cd22
Show file tree
Hide file tree
Showing 38 changed files with 748 additions and 706 deletions.
2 changes: 1 addition & 1 deletion .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
{
"label": "Run tests",
"type": "shell",
"command": "./node_modules/.bin/wattle.cmd",
"command": "yarn wattle",
"options": {
"cwd": "${workspaceFolder}" // Change this if your tests aren't using the top-level node_modules
},
Expand Down
Binary file modified assets/logging-screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"@types/react": ">=16.8",
"@types/react-dom": ">=16.8",
"parcel-bundler": "*",
"typescript": "~4"
"typescript": "~5"
},
"browserslist": ["last 2 Chrome versions"]
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
},
"devDependencies": {
"ts-node": "*",
"typescript": "~4",
"typescript": "~5",
"wattle": "~0"
},
"resolutions": {
Expand Down
10 changes: 6 additions & 4 deletions packages/event-reduce-react/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "event-reduce-react",
"description": "React integration for event-reduce: state management based on reducing observable events into state",
"version": "0.4.9",
"version": "0.5.0",
"author": "Simon Oxtoby",
"homepage": "https://github.com/soxtoby/event-reduce",
"repository": {
Expand All @@ -16,13 +16,15 @@
"lib"
],
"dependencies": {
"event-reduce": "^0.4.9"
"event-reduce": "^0.5",
"use-sync-external-store": "*"
},
"peerDependencies": {
"react": ">=16.8"
},
"devDependencies": {
"@types/react": ">=16.8",
"typescript": "~4"
"@types/use-sync-external-store": "*",
"typescript": "~5"
}
}
}
88 changes: 58 additions & 30 deletions packages/event-reduce-react/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import { asyncEvent, derive, event, IObservableValue, IReduction, reduce } from "event-reduce";
import { ensureValueOwner } from "event-reduce/lib/cleanup";
import { getObservableValues, getOrSetObservableValue } from "event-reduce/lib/decorators";
import { IObservableValue, IReduction, asyncEvent, derive, event, reduce } from "event-reduce";
import { changeOwnedValue, disposeModel } from "event-reduce/lib/cleanup";
import { ObservableValue } from "event-reduce/lib/observableValue";
import { useRef } from "react";
import { ValueOf } from "event-reduce/lib/types";
import { dispose } from "event-reduce/lib/utils";
import { DependencyList, useMemo, useRef } from "react";
import { useDispose, useOnce } from "./utils";

/** Creates a model that persists across renders of the component and cleans up when the component is unmounted. */
export function useModel<T extends object>(createModel: () => T) {
let modelOwner = useOnce(() => ({})); // Effectively makes the component the owner of the model for cleanup purposes
let model = useOnce(() => {
let model = createModel();
changeOwnedValue(modelOwner, undefined, model);
return model;
});
useDispose(() => disposeModel(modelOwner));
return model;
}

export function useEvent<T>(name?: string) {
return useOnce(() => event<T>(name));
}
Expand All @@ -13,46 +26,61 @@ export function useAsyncEvent<Result = void, Context = void>(name?: string) {
return useOnce(() => asyncEvent<Result, Context>(name))
}

export function useDerived<T>(getValue: () => T, name?: string): IObservableValue<T> {
/**
* Creates a derived value that persists across renders of the component and cleans up when the component is unmounted.
* @param unobservableDependencies - A list of dependencies that are not observable. The derived value will be updated when any of these change.
* If not specified, the derived value will be updated every render, but will still only *trigger* a re-render inside a reactive component if the derived value changes.
*/
export function useDerived<T>(getValue: () => T, name?: string): IObservableValue<T>;
export function useDerived<T>(getValue: () => T, unobservableDependencies?: DependencyList, name?: string): IObservableValue<T>;
export function useDerived<T>(getValue: () => T, nameOrUnobservableDependencies?: string | DependencyList, name?: string): IObservableValue<T> {
let unobservableDependencies: DependencyList | undefined;
[name, unobservableDependencies] = typeof nameOrUnobservableDependencies === 'string'
? [nameOrUnobservableDependencies, undefined]
: [name, nameOrUnobservableDependencies];

let derived = useOnce(() => derive(getValue, name));

useDispose(() => derived.unsubscribeFromSources());
useMemo(() => derived.update(getValue, 'render'), unobservableDependencies);

useDispose(() => derived[dispose]());

return derived;
}

export function useReduced<T>(initial: T, name?: string): IReduction<T> {
let reduction = useOnce(() => reduce(initial, name));

useDispose(() => reduction.unsubscribeFromSources());
useDispose(() => reduction[dispose]());

return reduction;
}

export function useAsObservableValues<T extends object>(values: T, name?: string) {
let valueModel = useRef({} as T);
let previousObservableValues = getObservableValues(valueModel.current) ?? {};
valueModel.current = {} as T;

export function useObservedProps<T extends object>(values: T, name: string = '(anonymous observed values)') {
let observableValues = useModel(() => ({} as Record<keyof T, ObservableValue<ValueOf<T>>>));
let nameBase = (name || '') + '.';

let keys = Array.from(new Set(Object.keys(valueModel.current).concat(Object.keys(values))));

for (let key of keys) {
let propValue = values[key as keyof T];
ensureValueOwner(propValue, undefined); // Prevent prop value from being owned by observable value below to avoid attempting cleanup

let observableValue = getOrSetObservableValue(valueModel.current, key,
() => previousObservableValues[key]
?? new ObservableValue<any>(() => nameBase + key, propValue));

observableValue.setValue(propValue);

Object.defineProperty(valueModel.current, key, {
get() { return observableValue.value; },
enumerable: true
});
}
// Update any values that are already being observed
for (let [key, observableValue] of Object.entries(observableValues) as [keyof T, ObservableValue<ValueOf<T>>][])
observableValue.setValue(values[key as keyof T]);

let latestValues = useRef(values);
latestValues.current = values;

// Create observable values as properties are accessed
return new Proxy({} as T, {
get(_, key) {
return (observableValues[key as keyof T]
??= new ObservableValue(
() => nameBase + String(key),
(latestValues.current)[key as keyof T]))
.value;
}
});
}

return valueModel.current;
export function useObserved<T>(value: T, name: string = '(anonymous observed value)') {
let observableValue = useModel(() => new ObservableValue(() => name, value));
observableValue.setValue(value);
return observableValue;
}
7 changes: 6 additions & 1 deletion packages/event-reduce-react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
import { cleanupOptions } from "event-reduce/lib/cleanup"

export * from "./hooks";
export * from "./rendering";
export * from "./rendering";

let baseSkip = cleanupOptions.skipCleanup;
cleanupOptions.skipCleanup = (value: object) => baseSkip(value) || '$$typeof' in value; // Skip react elements
128 changes: 65 additions & 63 deletions packages/event-reduce-react/src/rendering.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,24 @@
import { Unsubscribe, watch } from "event-reduce";
import { log, sourceTree } from "event-reduce/lib/logging";
import { collectAccessedValues, ObservableValue } from "event-reduce/lib/observableValue";
import { nameOfFunction } from "event-reduce";
import { Derivation } from "event-reduce/lib/derivation";
import { LogValue } from "event-reduce/lib/logging";
import { ObservableValue } from "event-reduce/lib/observableValue";
import { reactionQueue } from "event-reduce/lib/reactions";
import { createElement, forwardRef, ForwardRefExoticComponent, ForwardRefRenderFunction, Fragment, FunctionComponent, memo, MemoExoticComponent, PropsWithChildren, PropsWithoutRef, ReactElement, ReactNode, RefAttributes, useRef, useState, ValidationMap, WeakValidationMap } from "react";
import { useAsObservableValues } from "./hooks";
import { useDispose } from "./utils";

interface ContextlessFunctionComponent<P = {}> {
(props: PropsWithChildren<P>): ReactElement | null;
propTypes?: WeakValidationMap<P>;
contextTypes?: ValidationMap<any>;
defaultProps?: Partial<P>;
displayName?: string;
}

export type ReactiveComponent<Component extends ContextlessFunctionComponent<any> | ForwardRefRenderFunction<any, any>> =
Component extends ContextlessFunctionComponent<any> ? MemoExoticComponent<Component>
: Component extends ForwardRefRenderFunction<infer Ref, infer Props> ? MemoExoticComponent<ForwardRefExoticComponent<PropsWithoutRef<Props> & RefAttributes<Ref>>>
: never;
import { dispose } from "event-reduce/lib/utils";
import { Children, Fragment, ReactElement, ReactNode, createElement, isValidElement, useCallback, useEffect } from "react";
import { useSyncExternalStore } from "use-sync-external-store/shim";
import { useDispose, useOnce } from "./utils";

export function Reactive(props: { name?: string; children: () => ReactNode; }): ReactElement {
return useReactive(props.name || 'Derived', () => createElement(Fragment, { children: props.children() }));
}

export function reactive<Component extends (ContextlessFunctionComponent<any> | ForwardRefRenderFunction<any, any>)>(component: Component): ReactiveComponent<Component> {
let componentName = component.displayName || component.name || 'ReactiveComponent';
let reactiveComponent = ((...args: Parameters<Component>) => { // Important to use rest operator here so react ignores function arity
return useReactive(componentName, () => {
let [props, ...otherArgs] = args;
let observableProps = useAsObservableValues(props, `${componentName}.props`);
return component(observableProps, ...otherArgs as [any])
});
}) as ReactiveComponent<Component>;
reactiveComponent.displayName = componentName;
export function reactive<Args extends any[]>(component: (...args: Args) => ReactElement | null) {
let componentName = nameOfFunction(component) || 'ReactiveComponent';

if (component.length == 2)
reactiveComponent = forwardRef(reactiveComponent) as ReactiveComponent<Component>;
reactiveComponent = memo<Component>(reactiveComponent as FunctionComponent<any>) as ReactiveComponent<Component>;
reactiveComponent.displayName = componentName;
return reactiveComponent;
const reactiveComponentName = `reactive(${componentName})`;
return {
[reactiveComponentName]: (...args: Args) => useReactive(componentName, () => component(...args))
}[reactiveComponentName];
}

export function useReactive<T>(deriveValue: () => T): T;
Expand All @@ -48,39 +28,61 @@ export function useReactive<T>(nameOrDeriveValue: string | (() => T), maybeDeriv
? [nameOrDeriveValue, maybeDeriveValue!]
: ['ReactiveValue', nameOrDeriveValue];

let [reactionCount, setRerenderCount] = useState(0);
let derivation = useSyncDerivation<T>(name);
return useRenderValue<T>(derivation, deriveValue);
}

// Unsubscribe from previous render before rendering again
let unsubscribeFromLatestRender = useRef((() => { }) as Unsubscribe);
unsubscribeFromLatestRender.current();
unsubscribeFromLatestRender.current = unsubscribeFromThisRender;
useDispose(() => unsubscribeFromLatestRender.current());
function useSyncDerivation<T>(name: string) {
// Using a bogus derive function because we'll provide a new one every render
let derivedValue = useOnce(() => new RenderedValue<T>(() => name, () => undefined!));
useDispose(() => derivedValue[dispose]());

let value!: T;
let render = useOnce(() => new ObservableValue(() => `${name}.render`, { invalidatedBy: "(nothing)" }));

let watcher = watch(
() => {
let newSources: Set<ObservableValue<any>>;
log('⚛ (render)', name, [], () => ({
'Reaction count': reactionCount,
Sources: { get list() { return sourceTree(Array.from(newSources)); } }
}), () => newSources = collectAccessedValues(() => value = deriveValue()));
},
name);
useEffect(() => derivedValue.subscribe(() => {
let invalidatedBy = derivedValue.invalidatedBy ?? "(unknown)";
reactionQueue.current.add(() => {
if (derivedValue.invalidatedBy == invalidatedBy) // Avoid unnecessary renders
render.setValue({ invalidatedBy })
});
}), []);

let cancelReaction = undefined as Unsubscribe | undefined;
let stopWatching = watcher.subscribe(changed => {
unsubscribeFromThisRender(); // Avoid queueing up extra renders if more sources change
cancelReaction = reactionQueue.current.add(() =>
setRerenderCount(c => c + 1)
);
});
useSyncExternalStore(useCallback(o => render.subscribe(o), []), () => render.value);

return value;
return derivedValue;
}

function unsubscribeFromThisRender() {
cancelReaction?.();
stopWatching();
watcher.unsubscribeFromSources();
}
function useRenderValue<T>(derivation: RenderedValue<T>, deriveValue: () => T) {
useCallback(function update() { derivation.update(deriveValue, 'render'); }, [deriveValue])(); // need to use a hook to be considered a hook in devtools
return derivation.value;
}

class RenderedValue<T> extends Derivation<T> {
protected override updatedEvent = '⚛️ (render)';
protected override invalidatedEvent = '⚛️🚩 (render invalidated)';
protected override loggedValue(value: T) {
if (process.env.NODE_ENV !== 'production' && isValidElement(value)) {
let xmlDoc = document.implementation.createDocument(null, null);
return new LogValue([
xmlTree(value),
{ ['React element']: value }
]);

function xmlTree<T>(node: T): Node {
if (isValidElement(node)) {
let type = (typeof node.type == 'string' ? node.type
: typeof node.type == 'function' ? nameOfFunction(node.type)
: typeof node.type == 'symbol' ? String(node.type)
: '???')
.split('(').at(-1)!.split(')')[0]; // Unwrap HOC names
let el = xmlDoc.createElement(type);
for (let child of Children.toArray((node.props as any).children))
el.appendChild(xmlTree(child));
return el;
}
return document.createTextNode(String(node));
}
}
return value;
}
}
4 changes: 2 additions & 2 deletions packages/event-reduce/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "event-reduce",
"description": "State management based on reducing observable events into state",
"version": "0.4.9",
"version": "0.5.0",
"author": "Simon Oxtoby",
"homepage": "https://github.com/soxtoby/event-reduce",
"repository": {
Expand All @@ -20,6 +20,6 @@
],
"devDependencies": {
"@types/node": "*",
"typescript": "~4"
"typescript": "~5"
}
}
Loading

0 comments on commit ff8cd22

Please sign in to comment.