@webappwiz/react 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jared Johnson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @webappwiz/react
2
+
3
+ Wiring React to the rest of webappwiz, and nothing else. Anything that would
4
+ work without React lives in `webappwiz/browser`, and this
5
+ package stays small on purpose.
6
+
7
+ ## Owning a resource for a mount
8
+
9
+ ```ts
10
+ import { useResource, useDisposerEffect } from "@webappwiz/react";
11
+
12
+ const parser = useResource(() => new Parser(source));
13
+
14
+ useDisposerEffect((disposer) => {
15
+ disposer.use(new WindowBackgroundObserver());
16
+ }, []);
17
+ ```
18
+
19
+ `useResource` builds a `Resource` during render and disposes it on unmount,
20
+ or when the factory changes. Its factory must be render-pure: React can abandon
21
+ a render before commit, and anything acquired there would never be disposed.
22
+ Acquire timers, subscriptions and workers in `useDisposerEffect` instead, which
23
+ runs after commit and disposes everything it registered on teardown.
24
+
25
+ ## Rendering something that raises events
26
+
27
+ ```ts
28
+ import { useReactive } from "@webappwiz/react";
29
+
30
+ const title = useReactive(player, (player) => player.title(), ["change"]);
31
+ ```
32
+
33
+ `useReactive` reads a projection of an `Eventful` and re-renders when the named
34
+ events change what it returns. The projection is compared shallowly, so an
35
+ event that changes nothing the component reads does not re-render it.
36
+
37
+ `useExternalStore` and `ReactiveExternalStore` are the layer underneath, for
38
+ when a store is wanted directly.
39
+
40
+ The source is captured on the first render, so pass a stable one: a controller
41
+ or a singleton, not an object built during render. Remount with a `key` if it
42
+ has to change.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A source `useExternalStore` can subscribe to: the `useSyncExternalStore`
3
+ * contract, as an object so the three calls travel together.
4
+ *
5
+ * The methods must keep a stable identity across renders. Bind them in the
6
+ * constructor or write them as arrow properties; a `.bind()` at the call site
7
+ * makes React tear down and rebuild the subscription every render.
8
+ */
9
+ export interface ExternalStore<Snapshot> {
10
+ subscribe(onStoreChange: () => void): () => void;
11
+ getSnapshot(): Snapshot;
12
+ getServerSnapshot(): Snapshot;
13
+ }
@@ -0,0 +1,20 @@
1
+ import type { Eventful, EventMapOf } from "webappwiz/events";
2
+ import type { ExternalStore } from "./external-store.js";
3
+ /**
4
+ * Projects part of an `Eventful` source into a snapshot, recomputing it when
5
+ * any of the named events fire and notifying React only when the projection
6
+ * actually changed (compared shallowly).
7
+ *
8
+ * The methods are arrow properties so their identity survives being passed to
9
+ * `useSyncExternalStore` on every render.
10
+ */
11
+ export declare class ReactiveExternalStore<Source extends Eventful<Record<string, unknown>>, State> implements ExternalStore<State> {
12
+ private source;
13
+ private select;
14
+ private events;
15
+ private state;
16
+ constructor(source: Source, select: (source: Source) => State, events: Array<string & keyof EventMapOf<Source>>);
17
+ subscribe: (onStoreChange: () => void) => (() => void);
18
+ getSnapshot: () => State;
19
+ getServerSnapshot: () => State;
20
+ }
@@ -0,0 +1,2 @@
1
+ /** Compares two values by their own enumerable keys, one level deep. */
2
+ export declare function shallowEqual<T>(left: T, right: T): boolean;
package/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type { ExternalStore } from "./external-store/external-store.js";
2
+ export { ReactiveExternalStore } from "./external-store/reactive-external-store.js";
3
+ export { type AppendOnlyDisposer, type DisposerEffectCallback, useDisposerEffect, } from "./use-disposer-effect.js";
4
+ export { useExternalStore } from "./use-external-store.js";
5
+ export { useReactive } from "./use-reactive.js";
6
+ export { useResource } from "./use-resource.js";
package/index.js ADDED
@@ -0,0 +1,108 @@
1
+ // external-store/reactive-external-store.ts
2
+ import { Disposer } from "webappwiz/disposable";
3
+
4
+ // external-store/shallow-equal.ts
5
+ function shallowEqual(left, right) {
6
+ if (Object.is(left, right)) {
7
+ return true;
8
+ }
9
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) {
10
+ return false;
11
+ }
12
+ const keysLeft = Object.keys(left);
13
+ const keysRight = Object.keys(right);
14
+ if (keysLeft.length !== keysRight.length) {
15
+ return false;
16
+ }
17
+ return keysLeft.every((key) => Object.hasOwn(right, key) && Object.is(left[key], right[key]));
18
+ }
19
+
20
+ // external-store/reactive-external-store.ts
21
+ class ReactiveExternalStore {
22
+ source;
23
+ select;
24
+ events;
25
+ state;
26
+ constructor(source, select, events) {
27
+ this.source = source;
28
+ this.select = select;
29
+ this.events = events;
30
+ this.state = this.select(this.source);
31
+ }
32
+ subscribe = (onStoreChange) => {
33
+ const disposer = new Disposer;
34
+ const reconcile = () => {
35
+ const next = this.select(this.source);
36
+ if (!shallowEqual(this.state, next)) {
37
+ this.state = next;
38
+ onStoreChange();
39
+ }
40
+ };
41
+ for (const event of this.events) {
42
+ disposer.defer(this.source.events.on(event, reconcile));
43
+ }
44
+ reconcile();
45
+ return () => {
46
+ disposer.dispose();
47
+ };
48
+ };
49
+ getSnapshot = () => this.state;
50
+ getServerSnapshot = () => this.state;
51
+ }
52
+ // use-disposer-effect.ts
53
+ import { useEffect } from "react";
54
+ import { Disposer as Disposer2 } from "webappwiz/disposable";
55
+ function useDisposerEffect(effect, deps) {
56
+ useEffect(() => {
57
+ const disposer = new Disposer2;
58
+ try {
59
+ effect(disposer);
60
+ } catch (error) {
61
+ disposer.dispose();
62
+ throw error;
63
+ }
64
+ return () => {
65
+ disposer.dispose();
66
+ };
67
+ }, deps);
68
+ }
69
+ // use-external-store.ts
70
+ import { useSyncExternalStore } from "react";
71
+ function useExternalStore(store) {
72
+ return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
73
+ }
74
+ // use-reactive.ts
75
+ import { useRef, useState } from "react";
76
+ function useReactive(source, select, events) {
77
+ const selectRef = useRef(select);
78
+ selectRef.current = select;
79
+ const [store] = useState(() => new ReactiveExternalStore(source, (source2) => selectRef.current(source2), events));
80
+ return useExternalStore(store);
81
+ }
82
+ // use-resource.ts
83
+ import { useRef as useRef2, useState as useState2 } from "react";
84
+ function useResource(factory) {
85
+ const [generation, setGeneration] = useState2(0);
86
+ const retiredRef = useRef2(new WeakSet);
87
+ const memo = useRef2(null);
88
+ if (memo.current === null || memo.current.factory !== factory || memo.current.generation !== generation || retiredRef.current.has(memo.current.instance)) {
89
+ memo.current = { factory, generation, instance: factory() };
90
+ }
91
+ const instance = memo.current.instance;
92
+ useDisposerEffect((disposer) => {
93
+ if (retiredRef.current.has(instance)) {
94
+ setGeneration((generation2) => generation2 + 1);
95
+ return;
96
+ }
97
+ disposer.use(instance);
98
+ disposer.defer(() => retiredRef.current.add(instance));
99
+ }, [instance]);
100
+ return instance;
101
+ }
102
+ export {
103
+ useResource,
104
+ useReactive,
105
+ useExternalStore,
106
+ useDisposerEffect,
107
+ ReactiveExternalStore
108
+ };
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@webappwiz/react",
3
+ "version": "0.0.1",
4
+ "description": "Wiring React to the rest of webappwiz: resources, reactive objects and external stores",
5
+ "license": "MIT",
6
+ "author": "Jared Johnson",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/jaredjj3/webappwiz.git",
10
+ "directory": "packages/react"
11
+ },
12
+ "type": "module",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "dependencies": {
17
+ "webappwiz": "^0.0.1"
18
+ },
19
+ "peerDependencies": {
20
+ "react": "^19"
21
+ },
22
+ "main": "./index.js",
23
+ "types": "./index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./index.d.ts",
27
+ "default": "./index.js"
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,10 @@
1
+ import { type DependencyList } from "react";
2
+ import { Disposer } from "webappwiz/disposable";
3
+ /** A `Disposer` that can take on resources but cannot be disposed by the effect. */
4
+ export type AppendOnlyDisposer = Pick<Disposer, "disposed" | "use" | "adopt" | "defer">;
5
+ export type DisposerEffectCallback = (disposer: AppendOnlyDisposer) => void;
6
+ /**
7
+ * Runs an effect that acquires resources, disposing everything it registered
8
+ * when the effect is torn down.
9
+ */
10
+ export declare function useDisposerEffect(effect: DisposerEffectCallback, deps: DependencyList): void;
@@ -0,0 +1,3 @@
1
+ import type { ExternalStore } from "./external-store/external-store.js";
2
+ /** Subscribes to an `ExternalStore` and returns its current snapshot. */
3
+ export declare function useExternalStore<Snapshot>(store: ExternalStore<Snapshot>): Snapshot;
@@ -0,0 +1,12 @@
1
+ import type { Eventful, EventMapOf } from "webappwiz/events";
2
+ /**
3
+ * Reads a projection of an `Eventful` source, re-rendering when any of the
4
+ * named events changes what `select` returns.
5
+ *
6
+ * The store is built once per mount. `select` is read through a ref, so an
7
+ * inline arrow that closes over fresh props still sees the latest values, but
8
+ * `source` and `events` are captured on the first render: pass a stable source
9
+ * (a controller or singleton, not a per-render object) and a fixed event list.
10
+ * Remount against a new source with a `key` if it ever needs to change.
11
+ */
12
+ export declare function useReactive<Source extends Eventful<Record<string, unknown>>, State>(source: Source, select: (source: Source) => State, events: Array<string & keyof EventMapOf<Source>>): State;
@@ -0,0 +1,22 @@
1
+ import type { Resource } from "webappwiz/disposable";
2
+ /**
3
+ * Owns a disposable for the lifetime of a single mount. The factory builds the
4
+ * resource; it is disposed when the component unmounts or when the factory's
5
+ * identity changes (a constructor argument changed), at which point a fresh
6
+ * instance is built.
7
+ *
8
+ * Pass a factory, not a prebuilt instance: a factory is what lets the hook
9
+ * rebuild rather than hand back one it has already disposed. The instance is
10
+ * built during render, so a dependent resource built later in the same render
11
+ * reads the rebuilt one straight away.
12
+ *
13
+ * REQUIREMENT: the factory and the disposable's constructor must be
14
+ * render-pure. They may wire up in-memory state and pure helper objects, but
15
+ * must not acquire resources that need cleanup (timers, event or DOM
16
+ * subscriptions, workers, audio nodes, network, global mutation). Because
17
+ * construction happens at render, a render React abandons before commit (an
18
+ * interrupted concurrent render, a throw elsewhere in the tree) builds an
19
+ * instance whose disposing effect never runs, and anything acquired there
20
+ * leaks. Acquire such resources after commit, via `useDisposerEffect`.
21
+ */
22
+ export declare function useResource<T extends Resource>(factory: () => T): T;