From 830107a453b04d08586ea7768c13b409eaf8f3f5 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 9 May 2024 13:49:55 +0200 Subject: [PATCH] feat(react): add state support --- .changeset/silent-papayas-trade.md | 5 + packages/react/package.json | 22 +- packages/react/src/index.ts | 3 +- packages/react/src/observable.ts | 70 ++++++ .../react/src/react-tree-builder.test.tsx | 4 + packages/react/src/react-tree-builder.ts | 206 ++++++++++++++++-- packages/react/src/root.test.tsx | 86 +++++++- pnpm-lock.yaml | 7 + 8 files changed, 369 insertions(+), 34 deletions(-) create mode 100644 .changeset/silent-papayas-trade.md create mode 100644 packages/react/src/observable.ts diff --git a/.changeset/silent-papayas-trade.md b/.changeset/silent-papayas-trade.md new file mode 100644 index 0000000..7a08b79 --- /dev/null +++ b/.changeset/silent-papayas-trade.md @@ -0,0 +1,5 @@ +--- +"@coldwired/react": minor +--- + +add state support diff --git a/packages/react/package.json b/packages/react/package.json index 405e5fc..daa6bdb 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -2,7 +2,9 @@ "name": "@coldwired/react", "description": "React support for @coldwired", "license": "MIT", - "files": ["dist"], + "files": [ + "dist" + ], "main": "./dist/index.cjs.js", "module": "./dist/index.es.js", "types": "./dist/types/index.d.ts", @@ -32,12 +34,13 @@ "@coldwired/utils": "^0.13.0" }, "devDependencies": { - "react-error-boundary": "^4.0.13", "@coldwired/actions": "*", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", "html-entities": "^2.4.0", "react-aria-components": "^1.2.0", + "react-error-boundary": "^4.0.13", + "react-fast-compare": "^3.2.2", "zod": "^3.23.4" }, "peerDependencies": { @@ -55,15 +58,24 @@ "eslintConfig": { "root": true, "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint"], - "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], + "plugins": [ + "@typescript-eslint" + ], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], "rules": { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-redeclare": "off" }, "overrides": [ { - "files": ["vite.config.js", "vitest.config.ts"], + "files": [ + "vite.config.js", + "vitest.config.ts" + ], "env": { "node": true } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b1668da..04c9bce 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,3 +1,4 @@ export * from './root'; export * from './plugin'; -export { hydrate, preload, createReactTree } from './react-tree-builder'; +export * from './observable'; +export { hydrate, preload, createReactTree, createState, type State } from './react-tree-builder'; diff --git a/packages/react/src/observable.ts b/packages/react/src/observable.ts new file mode 100644 index 0000000..b3fb26e --- /dev/null +++ b/packages/react/src/observable.ts @@ -0,0 +1,70 @@ +export interface Observer { + next: NextChannel; +} + +type Connect = (observer: Observer) => Disconnect; +type Disconnect = () => void; + +export type NextChannel = (value: T) => void; +export type ObserverOrNext = Observer | NextChannel; + +export type Unsubscribe = () => void; +export type Subscription = { unsubscribe: Unsubscribe }; + +/** + * `Observable` is a standard interface that's useful for modeling multiple, + * asynchronous events. + */ +export class Observable implements Observable { + #connect: Connect; + + /** + * The provided function should receive an observer and connect that + * observer's `next` method to an event source (for instance, + * `element.addEventListener('click', observer.next)`). + * + * It must return a function that will disconnect the observer from the event + * source. + */ + constructor(connect: Connect) { + this.#connect = connect; + } + + /** + * `subscribe` uses the function supplied to the constructor to connect an + * observer to an event source. Each observer is connected independently: + * each call to `subscribe` calls `connect` with the new observer. + * + * To disconnect the observer from the event source, call `unsubscribe` on the + * returned subscription. + * + * Note: `subscribe` accepts either a function or an object with a + * next method. + */ + subscribe(observerOrNext: ObserverOrNext): Subscription { + // For simplicity's sake, `subscribe` accepts `next` either as either an + // anonymous function or wrapped in an object (the observer). Since + // `connect` always expects to receive an observer, wrap any loose + // functions in an object. + const observer = wrapWithObserver(observerOrNext); + + let disconnect: Disconnect | undefined = this.#connect(observer); + + return { + unsubscribe() { + if (disconnect) { + disconnect(); + disconnect = undefined; + } + }, + }; + } +} + +function wrapWithObserver(listener: ObserverOrNext): Observer { + if (typeof listener == 'function') { + return { next: listener }; + } else { + return listener; + } +} diff --git a/packages/react/src/react-tree-builder.test.tsx b/packages/react/src/react-tree-builder.test.tsx index 3465f4c..0c07c5b 100644 --- a/packages/react/src/react-tree-builder.test.tsx +++ b/packages/react/src/react-tree-builder.test.tsx @@ -10,6 +10,7 @@ import { hydrate, preload, defaultSchema, + createNullState, type ReactComponent, } from './react-tree-builder'; @@ -61,6 +62,7 @@ describe('@coldwired/react', () => { const tree = createReactTree( { tagName: 'div', attributes: { className: 'title' }, children: 'Hello' }, {}, + createNullState(), ); const html = renderToStaticMarkup(tree); expect(html).toBe('
Hello
'); @@ -79,6 +81,7 @@ describe('@coldwired/react', () => { ], }, {}, + createNullState(), ); const html = renderToStaticMarkup(tree); expect(html).toBe( @@ -122,6 +125,7 @@ describe('@coldwired/react', () => { }, ], { Greeting, FieldSet }, + createNullState(), ); const html = renderToStaticMarkup(tree); expect(html).toBe( diff --git a/packages/react/src/react-tree-builder.ts b/packages/react/src/react-tree-builder.ts index 14f90be..1be35fe 100644 --- a/packages/react/src/react-tree-builder.ts +++ b/packages/react/src/react-tree-builder.ts @@ -1,16 +1,22 @@ import type { ComponentType, ReactNode, Key } from 'react'; -import { createElement, Fragment } from 'react'; +import { createElement, Fragment, useState, useMemo, memo } from 'react'; import { decode as htmlDecode } from 'html-entities'; +import isEqual from 'react-fast-compare'; + +import { Observable, type Observer } from './observable'; type Child = string | ReactElement | ReactComponent; type PrimitiveValue = string | number | boolean | null | undefined; type JSONValue = PrimitiveValue | Array | { [key: string]: JSONValue }; +type EventHandler = (value: ReactValue | Event) => void; type ReactValue = | PrimitiveValue | Date | bigint | Array - | { [key: string]: ReactValue }; + | { [key: string]: ReactValue } + | EventHandler + | Observable; export type ReactElement = { tagName: string; attributes: Record; @@ -56,8 +62,78 @@ export function hydrate( schema?: Partial, ): ReactNode { const childNodes = getChildNodes(documentOrFragment); - const { children } = hydrateChildNodes(childNodes, Object.assign({}, defaultSchema, schema)); - return createReactTree(children, manifest); + const { children: tree, withState } = hydrateChildNodes( + childNodes, + Object.assign({}, defaultSchema, schema), + ); + if (withState) { + return createElement(RootComponent, { tree, manifest }); + } + return createReactTree(tree, manifest, createNullState()); +} + +const RootComponent = memo(function RootComponent({ + tree, + manifest, +}: { + tree: Child[]; + manifest: Manifest; +}) { + const state = useLocalState(); + return createReactTree(tree, manifest, state); +}, isEqual); + +export interface State { + get(key: string, defaultValue?: ReactValue): ReactValue; + set(key: string, value: ReactValue): void; + observe(key: string): Observable; +} +type StateValue = Record; + +export function createState( + state: StateValue, + setState: (valueOrUpdate: StateValue | ((state: StateValue) => StateValue)) => void, +): State { + const registry = new Map>>(); + return { + set: (key, value) => { + setState((state) => ({ ...state, [key]: value })); + const observers = registry.get(key); + if (observers) { + for (const observer of observers) { + observer.next(value); + } + } + }, + get: (key, defaultValue) => state[key] ?? defaultValue, + observe: (key) => { + return new Observable((observer) => { + let observers = registry.get(key); + if (!observers) { + observers = new Set(); + registry.set(key, observers); + } + observers.add(observer); + return () => { + observers.delete(observer); + if (observers.size == 0) { + registry.delete(key); + } + }; + }); + }, + }; +} + +export function createNullState() { + return createState({}, () => { + throw new Error('Cannot set state on null state'); + }); +} + +function useLocalState(): State { + const [state, setState] = useState({}); + return useMemo(() => createState(state, setState), [state]); } export function preload( @@ -75,17 +151,21 @@ export function preload( return loader([...componentNames]); } -export function createReactTree(tree: Child | Child[], manifest: Manifest): ReactNode { +export function createReactTree( + tree: Child | Child[], + manifest: Manifest, + state: State, +): ReactNode { if (Array.isArray(tree)) { return createElement( Fragment, {}, - tree.map((child, i) => createChild(child, manifest, i)), + tree.map((child, i) => createChild(child, manifest, state, i)), ); } else if (typeof tree == 'string') { return createElement(Fragment, {}, tree); } - return createElementOrComponent(tree, manifest); + return createElementOrComponent(tree, manifest, state); } function getChildNodes(documentOrFragment: Document | DocumentFragmentLike) { @@ -97,10 +177,11 @@ function getChildNodes(documentOrFragment: Document | DocumentFragmentLike) { type HydrateResult = { children: Child[]; props: Record; + withState: boolean; }; function hydrateChildNodes(childNodes: NodeListOf, schema: Schema): HydrateResult { - const result: HydrateResult = { children: [], props: {} }; + const result: HydrateResult = { children: [], props: {}, withState: false }; childNodes.forEach((childNode) => { if (isTextNode(childNode)) { const text = childNode.textContent; @@ -109,7 +190,11 @@ function hydrateChildNodes(childNodes: NodeListOf, schema: Schema): H } } else if (isElementNode(childNode)) { const tagName = childNode.tagName.toLowerCase(); - const { children, props } = hydrateChildNodes(childNode.childNodes, schema); + const { + children, + props, + withState: childrenWithState, + } = hydrateChildNodes(childNode.childNodes, schema); if (tagName == schema.componentTagName) { const name = childNode.getAttribute(schema.nameAttribute); if (!name) { @@ -117,9 +202,13 @@ function hydrateChildNodes(childNodes: NodeListOf, schema: Schema): H `Missing "${schema.nameAttribute}" attribute on <${schema.componentTagName}>`, ); } + const [hydratedProps, withState] = hydrateProps(childNode, schema.propsAttribute); + if (withState || childrenWithState) { + result.withState = true; + } result.children.push({ name, - props: { ...hydrateProps(childNode, schema.propsAttribute), ...props }, + props: { ...hydratedProps, ...props }, children: children.length > 0 ? children : undefined, }); } else { @@ -173,14 +262,19 @@ function decodeProps(props: string): ReactComponent['props'] { return JSON.parse(htmlDecode(props)); } -function hydrateProps(childNode: HTMLElement, propsAttribute: string): ReactComponent['props'] { +function hydrateProps( + childNode: HTMLElement, + propsAttribute: string, +): [props: ReactComponent['props'], withState: boolean] { const serializedProps = childNode.getAttribute(propsAttribute); - return serializedProps ? decodeProps(serializedProps) : {}; + const withState = serializedProps ? statePropTypeRegExp.test(serializedProps) : false; + return [serializedProps ? decodeProps(serializedProps) : {}, withState]; } function createElementOrComponent( child: ReactElement | ReactComponent, manifest: Manifest, + state: State, key?: Key, ): ReactNode { if ('tagName' in child) { @@ -195,9 +289,9 @@ function createElementOrComponent( child.tagName, attributes, Array.isArray(child.children) - ? child.children.map((child, i) => createChild(child, manifest, i)) + ? child.children.map((child, i) => createChild(child, manifest, state, i)) : child.children - ? createChild(child.children, manifest) + ? createChild(child.children, manifest, state) : undefined, ); } @@ -208,9 +302,9 @@ function createElementOrComponent( const props: { [key: string]: ReactValue } = Object.fromEntries( Object.entries(child.props).map(([key, value]) => { if (isReactElement(value) || isReactComponent(value)) { - return [transformPropName(key), createElementOrComponent(value, manifest)]; + return [transformPropName(key), createElementOrComponent(value, manifest, state)]; } - return [transformPropName(key), transformPropValue(value)]; + return [transformPropName(key), transformPropValue(value, state)]; }), ); props.key = props.id ?? key; @@ -218,18 +312,23 @@ function createElementOrComponent( ComponentImpl, props, Array.isArray(child.children) - ? child.children.map((child, i) => createChild(child, manifest, i)) + ? child.children.map((child, i) => createChild(child, manifest, state, i)) : child.children - ? createChild(child.children, manifest) + ? createChild(child.children, manifest, state) : undefined, ); } -function createChild(child: Child, manifest: Manifest, key?: Key): ReactNode | string { +function createChild( + child: Child, + manifest: Manifest, + state: State, + key?: Key, +): ReactNode | string { if (typeof child == 'string') { return child; } - return createElementOrComponent(child, manifest, key); + return createElementOrComponent(child, manifest, state, key); } function transformAttributeName(name: string) { @@ -259,14 +358,27 @@ function transformPropName(name: string) { return transformAttributeName(name); } -function transformPropValue(value: JSONValue): ReactValue { +function transformPropValue(value: JSONValue, state: State): ReactValue { if (isPlainObject(value)) { - return Object.fromEntries( - Object.entries(value).map(([key, value]) => [key, transformPropValue(value)]), + const obj = Object.fromEntries( + Object.entries(value).map(([key, value]) => [key, transformPropValue(value, state)]), ); + if (isStateProp(obj)) { + switch (obj.__type__) { + case StatePropType.SET: + return (value: ReactValue | Event) => { + state.set(obj.key, getEventValue(value)); + }; + case StatePropType.GET: + return state.get(obj.key, obj.defaultValue); + case StatePropType.OBSERVE: + return state.observe(obj.key); + } + } + return obj; } if (Array.isArray(value)) { - return value.map(transformPropValue); + return value.map((value) => transformPropValue(value, state)); } if (typeof value == 'string') { return transformStringValue(value); @@ -274,6 +386,23 @@ function transformPropValue(value: JSONValue): ReactValue { return value; } +function getEventValue(eventOrValue: Event | ReactValue): ReactValue { + if (eventOrValue instanceof Event) { + const target = eventOrValue.target; + if (target instanceof HTMLInputElement) { + if (target.type == 'checkbox') { + return target.checked; + } else { + return target.value; + } + } else if (eventOrValue instanceof CustomEvent) { + return eventOrValue.detail; + } + throw new Error('Unsupported event type'); + } + return eventOrValue; +} + function transformStringValue(value: string): ReactValue { if (value[0] == '$') { switch (value[1]) { @@ -291,6 +420,35 @@ function transformStringValue(value: string): ReactValue { return value; } +enum StatePropType { + GET = '__get__', + SET = '__set__', + OBSERVE = '__observe__', +} +const statePropTypeSet = new Set(Object.values(StatePropType).map(String)); +const statePropTypeRegExp = new RegExp( + `"__type__":"(${StatePropType.GET}|${StatePropType.SET}|${StatePropType.OBSERVE})"`, +); +type StateProp = + | { + __type__: StatePropType.SET; + key: string; + } + | { + __type__: StatePropType.GET; + key: string; + defaultValue?: ReactValue; + } + | { + __type__: StatePropType.OBSERVE; + key: string; + }; + +function isStateProp(value: { [key: string]: ReactValue }): value is StateProp { + const type = value['__type__']; + return typeof type == 'string' && statePropTypeSet.has(type); +} + const reactAttributeMap: Record = { 'accept-charset': 'acceptCharset', accesskey: 'accessKey', diff --git a/packages/react/src/root.test.tsx b/packages/react/src/root.test.tsx index 30d410c..875003e 100644 --- a/packages/react/src/root.test.tsx +++ b/packages/react/src/root.test.tsx @@ -1,13 +1,29 @@ -import { describe, it, expect } from 'vitest'; -import { useState } from 'react'; -import { ComboBox, ListBox, ListBoxItem, Popover, Label, Input } from 'react-aria-components'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { useState, useEffect } from 'react'; +import { + ComboBox, + ListBox, + ListBoxItem, + Popover, + Label, + Input, + TextField, +} from 'react-aria-components'; +import { encode as htmlEncode } from 'html-entities'; +import { getByLabelText, fireEvent, waitFor } from '@testing-library/dom'; -import { createRoot, defaultSchema, type Manifest } from '.'; +import { createRoot, defaultSchema, type Manifest, type Observable } from '.'; +import type { ReactComponent } from './react-tree-builder'; const NAME_ATTRIBUTE = defaultSchema.nameAttribute; +const PROPS_ATTRIBUTE = defaultSchema.propsAttribute; const REACT_COMPONENT_TAG = defaultSchema.componentTagName; const DEFAULT_TAG_NAME = defaultSchema.fragmentTagName; +function encodeProps(props: ReactComponent['props']): string { + return htmlEncode(JSON.stringify(props)); +} + const Counter = () => { const [count, setCount] = useState(0); return ( @@ -17,11 +33,23 @@ const Counter = () => { ); }; +let observedValue = ''; +const Greeting = ({ name, nameChanges }: { name: string; nameChanges: Observable }) => { + useEffect(() => { + const subscription = nameChanges.subscribe((value) => { + observedValue = value; + }); + return subscription.unsubscribe; + }, [nameChanges]); + + return
Hello {name}!
; +}; const ComponentWithError = () => { throw new Error('Boom!'); }; const manifest: Manifest = { Counter, + Greeting, ComponentWithError, ComboBox, ListBox, @@ -29,9 +57,13 @@ const manifest: Manifest = { Popover, Label, Input, + TextField, }; describe('@coldwired/react', () => { + beforeEach(() => { + observedValue = ''; + }); describe('root', () => { it('render simple fragment', async () => { document.body.innerHTML = `<${DEFAULT_TAG_NAME}>
Hello
`; @@ -111,5 +143,51 @@ describe('@coldwired/react', () => { ); root.destroy(); }); + + it('render with state', async () => { + const textFieldProps = { + onChange: { __type__: '__set__', key: 'textValue' }, + }; + const greetingProps = { + name: { __type__: '__get__', key: 'textValue', defaultValue: 'Guest' }, + nameChanges: { __type__: '__observe__', key: 'textValue' }, + }; + document.body.innerHTML = `<${DEFAULT_TAG_NAME}> + <${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="TextField" ${PROPS_ATTRIBUTE}="${encodeProps(textFieldProps)}"> + <${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Label">My Name + <${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Input"> + + <${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Greeting" ${PROPS_ATTRIBUTE}="${encodeProps(greetingProps)}"> +
`; + const root = createRoot(document.getElementById('root')!, { + loader: (name) => Promise.resolve(manifest[name]), + }); + await root.mount(); + await root.render(document.body).done; + + expect(document.body.innerHTML).toEqual( + `<${DEFAULT_TAG_NAME}>
Hello Guest!
`, + ); + expect(observedValue).toEqual(''); + + fireEvent.change(getByLabelText(document.body, 'My Name'), { target: { value: 'Paul' } }); + + await waitFor(() => { + expect(document.body.innerHTML).toEqual( + `<${DEFAULT_TAG_NAME}>
Hello Paul!
`, + ); + expect(observedValue).toEqual('Paul'); + }); + + fireEvent.change(getByLabelText(document.body, 'My Name'), { target: { value: 'Greer' } }); + + await waitFor(() => { + expect(document.body.innerHTML).toEqual( + `<${DEFAULT_TAG_NAME}>
Hello Greer!
`, + ); + expect(observedValue).toEqual('Greer'); + }); + root.destroy(); + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d00bf8e..ebdb0f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,9 @@ importers: react-error-boundary: specifier: ^4.0.13 version: 4.0.13(react@18.2.0) + react-fast-compare: + specifier: ^3.2.2 + version: 3.2.2 zod: specifier: ^3.23.4 version: 3.23.4 @@ -4999,6 +5002,10 @@ packages: react: 18.2.0 dev: true + /react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + dev: true + /react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} dev: true