@phreshos/react 0.1.0 → 0.1.2

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,19 @@
1
+ Copyright (c) 2026 Zohayr SLILEH
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
package/README.md CHANGED
@@ -16,16 +16,105 @@ dependencies.
16
16
 
17
17
  It provides two kinds of adapter:
18
18
 
19
- - `CurrentProvider` resolves the Client SDK's current Program, Process, parent,
20
- and Window, then exposes them synchronously through `useProgram()`,
21
- `useProcess()`, `useParent()`, and `useWindow()`.
19
+ - `CurrentProvider` resolves exactly the current values named by its required
20
+ `provide` prop, then exposes them synchronously through `useProgram()`,
21
+ `useProcess()`, and `useParent()`.
22
+ - `HostProvider` subscribes before reading exactly the host values named by its
23
+ required `provide` prop. `useHostTheme()`, `useSurfaceSize()`, and
24
+ `usePointerPosition()` expose those values synchronously after resolution.
22
25
  - `useSubscribe()` and `useObserve()` own persistent ordinary-event
23
26
  registrations for one mounted React consumer.
27
+ - `useProgramState(program)`, `useProcessState(process)`, and
28
+ `useWindowState(window)` explicitly compose existing reads with future live
29
+ events for one mounted consumer. They add no state operation to another SDK.
24
30
  - `useObserveAsks()` adapts an Endpoint traffic surface's question observation,
25
31
  while `useObserveAnswers()` adapts a Server traffic surface's answer
26
32
  observation.
33
+ - `useScale(value)` and `useColor(value)` memoize the complete derived scale
34
+ around the explicit number or CSS color supplied by the component. They do
35
+ not read a Theme or choose a source value implicitly.
27
36
 
28
- All four hooks retain the latest value returned by their projector.
37
+ Window needs no React resolution: `current.window` is already a synchronous,
38
+ silent capability object. `CurrentProvider` therefore has no `window` selection
39
+ and the React SDK exposes no pass-through `useWindow()` hook.
40
+
41
+ The domain state hooks return only mutable state; identity and immutable
42
+ metadata remain on the supplied handle:
43
+
44
+ ```ts
45
+ useProgramState(program)
46
+ // { installed, processes } | undefined
47
+
48
+ useProcessState(process)
49
+ // { exited, serverExists, clientExists } | undefined
50
+
51
+ useWindowState(window)
52
+ // WindowState | undefined
53
+ ```
54
+
55
+ `WindowState` contains the observable Window properties only. The command-only
56
+ `window.surface` capability is intentionally absent: Program code may replace
57
+ or remove its target but cannot read or subscribe to it.
58
+
59
+ `undefined` means only that the initial explicit reads are pending. Each hook
60
+ opens its live subscriptions before those reads, then applies any intervening
61
+ events so an older result cannot overwrite newer state. If an initial read
62
+ rejects, the hook throws that original value during render for the nearest
63
+ React error boundary. It does not invent a fallback, retry, wait for a stopped
64
+ Client, or use an old Window snapshot as the fallback for that rejection. State
65
+ exists only while the hook is mounted and every registration is cleaned up on
66
+ unmount.
67
+
68
+ The Window capability outlives a stopped Client, but live Window state does
69
+ not. A component that spans Client lifecycle should use `useProcessState()` to
70
+ mount its `useWindowState()` child only while `clientExists` is true. Calling
71
+ the Window hook while the Client is absent lets the existing Window reads
72
+ reject normally.
73
+
74
+ ```tsx
75
+ const theme = useHostTheme()
76
+ const spacing = useScale(theme.spacing)
77
+ const radius = useScale(theme.radius)
78
+ const accent = useColor(theme.accent)
79
+ ```
80
+
81
+ The ordinary-event and traffic hooks retain the latest value returned by their projector.
82
+ `useSubscribe()` may omit that callback; its default projector is
83
+ `message => message`, so the hook retains the latest message unchanged.
84
+
85
+ ```tsx
86
+ import { host } from "@phreshos/client"
87
+ import { useSubscribe } from "@phreshos/react"
88
+
89
+ const surface = useSubscribe(host.surface, "resize")
90
+ ```
91
+
92
+ Host reads are asynchronous while subscriptions are live-only. `HostProvider`
93
+ subscribes before requesting each selected snapshot and prevents an older read
94
+ from overwriting a newer event:
95
+
96
+ ```tsx
97
+ import { HostProvider, useHostTheme, useSurfaceSize } from "@phreshos/react"
98
+
99
+ function Content() {
100
+ const theme = useHostTheme()
101
+ const surface = useSurfaceSize()
102
+
103
+ return <p style={{ color: theme.foreground }}>{surface.width} × {surface.height}</p>
104
+ }
105
+
106
+ function App() {
107
+ return <HostProvider provide={["theme", "surfaceSize"]} fallback={<p>Loading…</p>}>
108
+ <Content />
109
+ </HostProvider>
110
+ }
111
+ ```
112
+
113
+ The selection is required and non-empty. Nothing is read or subscribed merely
114
+ because either SDK was imported, and an unselected host value never enters the
115
+ Client. `pointerPosition` is permission-guarded: selecting it does not request
116
+ permission, and resolution fails unless the Program already holds `pointer`.
117
+ The provider renders its fallback until every selected value resolves.
29
118
 
30
119
  ```tsx
31
120
  import { current } from "@phreshos/client"
@@ -47,13 +136,18 @@ function Counter() {
47
136
 
48
137
  export default function App() {
49
138
  return (
50
- <CurrentProvider fallback={<p>Loading…</p>} waitServer>
139
+ <CurrentProvider provide={["program"]} fallback={<p>Loading…</p>} waitServer>
51
140
  <Counter />
52
141
  </CurrentProvider>
53
142
  )
54
143
  }
55
144
  ```
56
145
 
146
+ Using a current or host hook outside its provider, or without selecting its
147
+ value, throws a configuration error. Neither provider supplies an implicit
148
+ "everything" selection, so adding a future capability cannot make it enter an
149
+ existing application.
150
+
57
151
  Each hook calls the registration's returned cleanup when the component no
58
152
  longer consumes it. Projectors may return a value or Promise; only the latest
59
153
  invocation may update the hook state. Projector failures are never converted
@@ -1,34 +1,34 @@
1
1
  import { type ReactNode } from "react";
2
- import { type Process, type Program, type Window } from "@phreshos/client";
3
- /**
4
- * Resolves the current domain handles before rendering its children.
5
- *
6
- * The provider owns no domain state. It only turns asynchronous Client SDK
7
- * navigation into synchronous React context for the hooks below it.
8
- */
9
- export default function CurrentProvider({ children, fallback, waitServer }: CurrentProviderProperties): string | number | bigint | boolean | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").FunctionComponentElement<import("react").ProviderProps<Readonly<{
10
- program: Program;
11
- process: Process;
12
- parent: Process | null;
13
- window: Window;
14
- }> | null>> | null | undefined;
15
- /** Returns the current Program from the nearest {@link CurrentProvider}. */
2
+ import { type Process, type Program } from "@phreshos/client";
3
+ declare const provisionNames: readonly ["program", "process", "parent"];
4
+ /** Resolves only the current handles explicitly selected by the program. */
5
+ export default function CurrentProvider({ children, fallback, provide, waitServer }: CurrentProviderProperties): import("react").FunctionComponentElement<SelectedCurrentProviderProperties>;
6
+ /** Returns the current Program selected by the nearest provider. */
16
7
  export declare function useProgram(): Program;
17
- /** Returns the current Process from the nearest {@link CurrentProvider}. */
8
+ /** Returns the current Process selected by the nearest provider. */
18
9
  export declare function useProcess(): Process;
19
- /** Returns the current Process's visible parent from the nearest provider. */
10
+ /** Returns the current Process's selected visible parent. */
20
11
  export declare function useParent(): Process | null;
21
- /** Returns the current Window from the nearest {@link CurrentProvider}. */
22
- export declare function useWindow(): Window;
23
- /** Properties accepted by {@link CurrentProvider}. */
12
+ /** Values that CurrentProvider can resolve. */
13
+ export type CurrentProvisionName = typeof provisionNames[number];
14
+ /** Required non-empty selection of current values. */
15
+ export type CurrentProvision = readonly [CurrentProvisionName, ...CurrentProvisionName[]];
16
+ /** Properties accepted by CurrentProvider. */
24
17
  export interface CurrentProviderProperties {
25
- /** Content rendered after every current handle has resolved. */
18
+ /** Content rendered after every selected current handle has resolved. */
26
19
  readonly children: ReactNode;
27
- /** Content rendered while current handles or optional Server readiness resolve. */
20
+ /** Content rendered while selected handles or optional Server readiness resolve. */
28
21
  readonly fallback: ReactNode;
22
+ /** Exact current values made available to descendant hooks. */
23
+ readonly provide: CurrentProvision;
29
24
  /**
30
25
  * Waits for the Process's Server before rendering children.
31
26
  * `true` uses the default ten-second deadline; a number sets milliseconds.
32
27
  */
33
28
  readonly waitServer?: boolean | number;
34
29
  }
30
+ type SelectedCurrentProviderProperties = Omit<CurrentProviderProperties, "provide" | "waitServer"> & Readonly<{
31
+ selection: readonly CurrentProvisionName[];
32
+ waitServer: boolean | number;
33
+ }>;
34
+ export {};
@@ -1,18 +1,26 @@
1
- import { createContext, createElement, useContext, useEffect, useState } from "react";
1
+ import { createContext, createElement, useContext, useEffect, useMemo, useState } from "react";
2
2
  import { current } from "@phreshos/client";
3
+ const provisionNames = ["program", "process", "parent"];
3
4
  const CurrentContext = createContext(null);
4
- /**
5
- * Resolves the current domain handles before rendering its children.
6
- *
7
- * The provider owns no domain state. It only turns asynchronous Client SDK
8
- * navigation into synchronous React context for the hooks below it.
9
- */
10
- export default function CurrentProvider({ children, fallback, waitServer = false }) {
5
+ /** Resolves only the current handles explicitly selected by the program. */
6
+ export default function CurrentProvider({ children, fallback, provide, waitServer = false }) {
7
+ const normalized = normalizeProvision(provide);
8
+ const selectionKey = normalized.join("\0");
9
+ const selection = useMemo(() => normalized, [selectionKey]);
10
+ return createElement(SelectedCurrentProvider, {
11
+ children,
12
+ fallback,
13
+ key: `${selectionKey}\0${String(waitServer)}`,
14
+ selection,
15
+ waitServer
16
+ });
17
+ }
18
+ function SelectedCurrentProvider({ children, fallback, selection, waitServer }) {
11
19
  const [resolution, setResolution] = useState({ status: "pending" });
12
20
  useEffect(() => {
13
21
  let active = true;
14
22
  setResolution({ status: "pending" });
15
- void resolveCurrent(waitServer).then(value => {
23
+ void resolveCurrent(selection, waitServer).then(value => {
16
24
  if (active)
17
25
  setResolution({ status: "ready", value });
18
26
  }, error => {
@@ -20,45 +28,66 @@ export default function CurrentProvider({ children, fallback, waitServer = false
20
28
  setResolution({ status: "error", error });
21
29
  });
22
30
  return () => { active = false; };
23
- }, [waitServer]);
31
+ }, [selection, waitServer]);
24
32
  if (resolution.status === "pending")
25
33
  return fallback;
26
34
  if (resolution.status === "error")
27
35
  throw resolution.error;
28
36
  return createElement(CurrentContext.Provider, { value: resolution.value }, children);
29
37
  }
30
- async function resolveCurrent(waitServer) {
38
+ async function resolveCurrent(selection, waitServer) {
31
39
  const serverReady = typeof waitServer === "number"
32
40
  ? current.server.waitReady(waitServer)
33
41
  : waitServer ? current.server.waitReady() : undefined;
34
- const [program, process, parent] = await Promise.all([
35
- current.program(),
36
- current.process(),
37
- current.parent(),
42
+ const [entries] = await Promise.all([
43
+ Promise.all(selection.map(resolveProvision)),
38
44
  serverReady
39
45
  ]);
40
- const window = await current.window();
41
- return { program, process, parent, window };
46
+ return {
47
+ provided: new Set(selection),
48
+ values: Object.fromEntries(entries)
49
+ };
50
+ }
51
+ async function resolveProvision(name) {
52
+ switch (name) {
53
+ case "program": return [name, await current.program()];
54
+ case "process": return [name, await current.process()];
55
+ case "parent": return [name, await current.parent()];
56
+ }
42
57
  }
43
- /** Returns the current Program from the nearest {@link CurrentProvider}. */
58
+ /** Returns the current Program selected by the nearest provider. */
44
59
  export function useProgram() {
45
- return useCurrent().program;
60
+ return useProvided("program");
46
61
  }
47
- /** Returns the current Process from the nearest {@link CurrentProvider}. */
62
+ /** Returns the current Process selected by the nearest provider. */
48
63
  export function useProcess() {
49
- return useCurrent().process;
64
+ return useProvided("process");
50
65
  }
51
- /** Returns the current Process's visible parent from the nearest provider. */
66
+ /** Returns the current Process's selected visible parent. */
52
67
  export function useParent() {
53
- return useCurrent().parent;
68
+ return useProvided("parent");
69
+ }
70
+ function useProvided(name) {
71
+ const context = useContext(CurrentContext);
72
+ if (!context)
73
+ throw new Error(`use${hookName(name)} must be used inside CurrentProvider`);
74
+ if (!context.provided.has(name))
75
+ throw new Error(`use${hookName(name)} requires "${name}" in CurrentProvider's provide prop`);
76
+ return context.values[name];
54
77
  }
55
- /** Returns the current Window from the nearest {@link CurrentProvider}. */
56
- export function useWindow() {
57
- return useCurrent().window;
78
+ function normalizeProvision(provide) {
79
+ if (!Array.isArray(provide) || provide.length === 0)
80
+ throw new Error("CurrentProvider's provide prop must select at least one current value");
81
+ const selected = new Set();
82
+ for (const name of provide) {
83
+ if (!provisionNames.includes(name))
84
+ throw new Error(`CurrentProvider cannot provide "${String(name)}"`);
85
+ if (selected.has(name))
86
+ throw new Error(`CurrentProvider's provide prop selects "${name}" more than once`);
87
+ selected.add(name);
88
+ }
89
+ return [...selected];
58
90
  }
59
- function useCurrent() {
60
- const value = useContext(CurrentContext);
61
- if (!value)
62
- throw new Error("Current hooks must be used inside CurrentProvider");
63
- return value;
91
+ function hookName(name) {
92
+ return name.charAt(0).toUpperCase() + name.slice(1);
64
93
  }
@@ -0,0 +1,28 @@
1
+ import { type ReactNode } from "react";
2
+ import { type PointerPosition, type Surface, type ThemeProperties } from "@phreshos/client";
3
+ declare const provisionNames: readonly ["theme", "surfaceSize", "pointerPosition"];
4
+ /** Requests and follows only the host values explicitly selected by the program. */
5
+ export default function HostProvider(properties: HostProviderProperties): import("react").FunctionComponentElement<SelectedHostProviderProperties>;
6
+ /** Returns the selected Theme snapshot and follows future change events. */
7
+ export declare function useHostTheme(): Readonly<ThemeProperties>;
8
+ /** Returns the selected Surface size and follows future resize events. */
9
+ export declare function useSurfaceSize(): Surface;
10
+ /** Returns the selected Pointer position and follows future move events. */
11
+ export declare function usePointerPosition(): PointerPosition | null;
12
+ /** Values that HostProvider can request and follow. */
13
+ export type HostProvisionName = typeof provisionNames[number];
14
+ /** Required non-empty selection of host values. */
15
+ export type HostProvision = readonly [HostProvisionName, ...HostProvisionName[]];
16
+ /** Properties accepted by HostProvider. */
17
+ export interface HostProviderProperties {
18
+ /** Content rendered after every selected host value has resolved. */
19
+ readonly children: ReactNode;
20
+ /** Content rendered while selected host values resolve. */
21
+ readonly fallback: ReactNode;
22
+ /** Exact host values requested and followed for mounted descendants. */
23
+ readonly provide: HostProvision;
24
+ }
25
+ type SelectedHostProviderProperties = HostProviderProperties & Readonly<{
26
+ selection: readonly HostProvisionName[];
27
+ }>;
28
+ export {};
@@ -0,0 +1,95 @@
1
+ import { createContext, createElement, useContext, useEffect, useMemo, useState, useSyncExternalStore } from "react";
2
+ import { host } from "@phreshos/client";
3
+ import LiveSnapshot from "./live-snapshot.js";
4
+ const provisionNames = ["theme", "surfaceSize", "pointerPosition"];
5
+ const HostContext = createContext(null);
6
+ /** Requests and follows only the host values explicitly selected by the program. */
7
+ export default function HostProvider(properties) {
8
+ const normalized = normalizeProvision(properties.provide);
9
+ const selectionKey = normalized.join("\0");
10
+ const selection = useMemo(() => normalized, [selectionKey]);
11
+ return createElement(SelectedHostProvider, { ...properties, key: selectionKey, selection });
12
+ }
13
+ function SelectedHostProvider({ children, fallback, selection }) {
14
+ const stores = useMemo(() => createStores(selection), [selection]);
15
+ const [resolution, setResolution] = useState({ status: "pending" });
16
+ useEffect(() => {
17
+ let active = true;
18
+ const selected = selection.map(name => stores[name]);
19
+ void Promise.all(selected.map(store => store.start())).then(() => {
20
+ if (active)
21
+ setResolution({ status: "ready" });
22
+ }, error => {
23
+ for (const store of selected)
24
+ store.stop();
25
+ if (active)
26
+ setResolution({ status: "error", error });
27
+ });
28
+ return () => {
29
+ active = false;
30
+ for (const store of selected)
31
+ store.stop();
32
+ };
33
+ }, [selection, stores]);
34
+ if (resolution.status === "pending")
35
+ return fallback;
36
+ if (resolution.status === "error")
37
+ throw resolution.error;
38
+ return createElement(HostContext.Provider, { value: { provided: new Set(selection), stores } }, children);
39
+ }
40
+ /** Returns the selected Theme snapshot and follows future change events. */
41
+ export function useHostTheme() {
42
+ return useProvided("theme");
43
+ }
44
+ /** Returns the selected Surface size and follows future resize events. */
45
+ export function useSurfaceSize() {
46
+ return useProvided("surfaceSize");
47
+ }
48
+ /** Returns the selected Pointer position and follows future move events. */
49
+ export function usePointerPosition() {
50
+ return useProvided("pointerPosition");
51
+ }
52
+ function useProvided(name) {
53
+ const context = useContext(HostContext);
54
+ if (!context)
55
+ throw new Error(`${hookNames[name]} must be used inside HostProvider`);
56
+ if (!context.provided.has(name))
57
+ throw new Error(`${hookNames[name]} requires "${name}" in HostProvider's provide prop`);
58
+ const store = context.stores[name];
59
+ return useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
60
+ }
61
+ function createStores(selection) {
62
+ const stores = {};
63
+ for (const name of selection) {
64
+ switch (name) {
65
+ case "theme":
66
+ stores.theme = new LiveSnapshot(() => host.theme.snapshot(), subscriber => host.theme.subscribe("change", subscriber));
67
+ break;
68
+ case "surfaceSize":
69
+ stores.surfaceSize = new LiveSnapshot(() => host.surface.size(), subscriber => host.surface.subscribe("resize", subscriber));
70
+ break;
71
+ case "pointerPosition":
72
+ stores.pointerPosition = new LiveSnapshot(() => host.pointer.position(), subscriber => host.pointer.subscribe("move", subscriber));
73
+ break;
74
+ }
75
+ }
76
+ return stores;
77
+ }
78
+ function normalizeProvision(provide) {
79
+ if (!Array.isArray(provide) || provide.length === 0)
80
+ throw new Error("HostProvider's provide prop must select at least one host value");
81
+ const selected = new Set();
82
+ for (const name of provide) {
83
+ if (!provisionNames.includes(name))
84
+ throw new Error(`HostProvider cannot provide "${String(name)}"`);
85
+ if (selected.has(name))
86
+ throw new Error(`HostProvider's provide prop selects "${name}" more than once`);
87
+ selected.add(name);
88
+ }
89
+ return [...selected];
90
+ }
91
+ const hookNames = {
92
+ theme: "useHostTheme",
93
+ surfaceSize: "useSurfaceSize",
94
+ pointerPosition: "usePointerPosition"
95
+ };
@@ -0,0 +1,18 @@
1
+ import type { Cleanup } from "@phreshos/client";
2
+ /** One explicitly requested snapshot followed by only future live changes. */
3
+ export default class LiveSnapshot<Value> {
4
+ private readonly read;
5
+ private readonly subscribeSource;
6
+ private readonly listeners;
7
+ private value;
8
+ private stopSource;
9
+ private generation;
10
+ private revision;
11
+ constructor(read: () => Promise<Value>, subscribeSource: (subscriber: (value: Value) => void) => Cleanup);
12
+ readonly snapshot: () => Value;
13
+ readonly subscribe: (listener: () => void) => Cleanup;
14
+ /** Starts the live subscription before requesting the current snapshot. */
15
+ start(): Promise<void>;
16
+ stop(): void;
17
+ private update;
18
+ }
@@ -0,0 +1,59 @@
1
+ const unavailable = Symbol("unavailable");
2
+ /** One explicitly requested snapshot followed by only future live changes. */
3
+ export default class LiveSnapshot {
4
+ read;
5
+ subscribeSource;
6
+ listeners = new Set();
7
+ value = unavailable;
8
+ stopSource = null;
9
+ generation = 0;
10
+ revision = 0;
11
+ constructor(read, subscribeSource) {
12
+ this.read = read;
13
+ this.subscribeSource = subscribeSource;
14
+ }
15
+ snapshot = () => {
16
+ if (this.value === unavailable)
17
+ throw new Error("The requested host value is not ready");
18
+ return this.value;
19
+ };
20
+ subscribe = (listener) => {
21
+ this.listeners.add(listener);
22
+ return () => { this.listeners.delete(listener); };
23
+ };
24
+ /** Starts the live subscription before requesting the current snapshot. */
25
+ async start() {
26
+ this.stop();
27
+ this.value = unavailable;
28
+ const generation = this.generation;
29
+ this.stopSource = this.subscribeSource(value => {
30
+ if (this.generation !== generation)
31
+ return;
32
+ this.revision++;
33
+ this.update(value);
34
+ });
35
+ const requestedAt = this.revision;
36
+ try {
37
+ const value = await this.read();
38
+ if (this.generation === generation && this.revision === requestedAt)
39
+ this.update(value);
40
+ }
41
+ catch (error) {
42
+ if (this.generation === generation && this.revision === requestedAt)
43
+ throw error;
44
+ }
45
+ }
46
+ stop() {
47
+ this.generation++;
48
+ this.revision = 0;
49
+ this.stopSource?.();
50
+ this.stopSource = null;
51
+ }
52
+ update(value) {
53
+ if (Object.is(this.value, value))
54
+ return;
55
+ this.value = value;
56
+ for (const listener of this.listeners)
57
+ listener();
58
+ }
59
+ }
@@ -0,0 +1,24 @@
1
+ import type { Cleanup } from "@phreshos/client";
2
+ export type StateReducer<State> = (state: State) => State;
3
+ export type StateFollower<State> = (reduce: (reducer: StateReducer<State>) => void) => Cleanup;
4
+ /** One mounted React snapshot composed from an explicit read and future events. */
5
+ export default class LiveState<State> {
6
+ private readonly read;
7
+ private readonly follow;
8
+ private readonly listeners;
9
+ private value;
10
+ private error;
11
+ private stopSource;
12
+ private pending;
13
+ private generation;
14
+ private active;
15
+ constructor(read: () => Promise<State>, follow: StateFollower<State>);
16
+ readonly snapshot: () => State | undefined;
17
+ readonly subscribe: (listener: () => void) => Cleanup;
18
+ private start;
19
+ private stop;
20
+ private fail;
21
+ private update;
22
+ private notify;
23
+ }
24
+ export declare function combineCleanups(...cleanups: Cleanup[]): Cleanup;
@@ -0,0 +1,95 @@
1
+ const noError = Symbol("no-error");
2
+ /** One mounted React snapshot composed from an explicit read and future events. */
3
+ export default class LiveState {
4
+ read;
5
+ follow;
6
+ listeners = new Set();
7
+ value;
8
+ error = noError;
9
+ stopSource = null;
10
+ pending = [];
11
+ generation = 0;
12
+ active = false;
13
+ constructor(read, follow) {
14
+ this.read = read;
15
+ this.follow = follow;
16
+ }
17
+ snapshot = () => {
18
+ if (this.error !== noError)
19
+ throw this.error;
20
+ return this.value;
21
+ };
22
+ subscribe = (listener) => {
23
+ this.listeners.add(listener);
24
+ if (!this.active)
25
+ this.start();
26
+ return () => {
27
+ this.listeners.delete(listener);
28
+ if (this.listeners.size === 0)
29
+ this.stop();
30
+ };
31
+ };
32
+ start() {
33
+ this.active = true;
34
+ this.value = undefined;
35
+ this.error = noError;
36
+ this.pending = [];
37
+ const generation = ++this.generation;
38
+ try {
39
+ this.stopSource = this.follow(reducer => {
40
+ if (this.generation !== generation)
41
+ return;
42
+ if (this.value === undefined)
43
+ this.pending.push(reducer);
44
+ else
45
+ this.update(reducer(this.value));
46
+ });
47
+ }
48
+ catch (error) {
49
+ this.fail(generation, error);
50
+ return;
51
+ }
52
+ void this.read().then(value => {
53
+ if (this.generation !== generation)
54
+ return;
55
+ for (const reducer of this.pending)
56
+ value = reducer(value);
57
+ this.pending = [];
58
+ this.update(value);
59
+ }, error => this.fail(generation, error));
60
+ }
61
+ stop() {
62
+ this.active = false;
63
+ this.generation++;
64
+ this.stopSource?.();
65
+ this.stopSource = null;
66
+ this.pending = [];
67
+ this.value = undefined;
68
+ this.error = noError;
69
+ }
70
+ fail(generation, error) {
71
+ if (this.generation !== generation)
72
+ return;
73
+ this.stopSource?.();
74
+ this.stopSource = null;
75
+ this.pending = [];
76
+ this.error = error;
77
+ this.notify();
78
+ }
79
+ update(value) {
80
+ if (Object.is(this.value, value))
81
+ return;
82
+ this.value = value;
83
+ this.notify();
84
+ }
85
+ notify() {
86
+ for (const listener of this.listeners)
87
+ listener();
88
+ }
89
+ }
90
+ export function combineCleanups(...cleanups) {
91
+ return () => {
92
+ for (const cleanup of cleanups)
93
+ cleanup();
94
+ };
95
+ }
package/dist/main.d.ts CHANGED
@@ -1,5 +1,11 @@
1
- export { default as CurrentProvider, useParent, useProcess, useProgram, useWindow, type CurrentProviderProperties } from "./current-provider.js";
1
+ export { default as CurrentProvider, useParent, useProcess, useProgram, type CurrentProvision, type CurrentProvisionName, type CurrentProviderProperties } from "./current-provider.js";
2
+ export { default as HostProvider, usePointerPosition, useSurfaceSize, useHostTheme, type HostProvision, type HostProvisionName, type HostProviderProperties } from "./host-provider.js";
2
3
  export { default as useSubscribe } from "./use-subscribe.js";
3
4
  export { default as useObserve } from "./use-observe.js";
5
+ export { default as useScale } from "./use-scale.js";
6
+ export { default as useColor } from "./use-color.js";
7
+ export { default as useProgramState, type ProgramState } from "./use-program-state.js";
8
+ export { default as useProcessState, type ProcessState } from "./use-process-state.js";
9
+ export { default as useWindowState } from "./use-window-state.js";
4
10
  export { default as useObserveAsks, type AskObservable } from "./use-observe-asks.js";
5
11
  export { default as useObserveAnswers, type AnswerObservable } from "./use-observe-answers.js";
package/dist/main.js CHANGED
@@ -1,5 +1,11 @@
1
- export { default as CurrentProvider, useParent, useProcess, useProgram, useWindow } from "./current-provider.js";
1
+ export { default as CurrentProvider, useParent, useProcess, useProgram } from "./current-provider.js";
2
+ export { default as HostProvider, usePointerPosition, useSurfaceSize, useHostTheme } from "./host-provider.js";
2
3
  export { default as useSubscribe } from "./use-subscribe.js";
3
4
  export { default as useObserve } from "./use-observe.js";
5
+ export { default as useScale } from "./use-scale.js";
6
+ export { default as useColor } from "./use-color.js";
7
+ export { default as useProgramState } from "./use-program-state.js";
8
+ export { default as useProcessState } from "./use-process-state.js";
9
+ export { default as useWindowState } from "./use-window-state.js";
4
10
  export { default as useObserveAsks } from "./use-observe-asks.js";
5
11
  export { default as useObserveAnswers } from "./use-observe-answers.js";
@@ -0,0 +1,3 @@
1
+ import { type ColorScale } from "@phreshos/core";
2
+ /** Returns the complete color scale derived from one explicit CSS color. */
3
+ export default function useColor(value: string): ColorScale;
@@ -0,0 +1,6 @@
1
+ import { useMemo } from "react";
2
+ import { color } from "@phreshos/core";
3
+ /** Returns the complete color scale derived from one explicit CSS color. */
4
+ export default function useColor(value) {
5
+ return useMemo(() => color(value), [value]);
6
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AnswerCapture, AnswerObserver, Cleanup } from "@phreshos/client";
2
2
  /** Any Server traffic surface capable of observing its outgoing answers. */
3
3
  export type AnswerObservable = Readonly<{
4
+ /** Observes answers sent by the target Server and returns its cleanup. */
4
5
  observeAnswers<Result = unknown>(observer: AnswerObserver<Result>): Cleanup;
5
6
  }>;
6
7
  /**
@@ -1,6 +1,7 @@
1
1
  import type { AskCapture, AskObserver, Cleanup } from "@phreshos/client";
2
2
  /** Any Endpoint traffic surface capable of observing its outgoing questions. */
3
3
  export type AskObservable = Readonly<{
4
+ /** Observes questions sent by the target Endpoint and returns its cleanup. */
4
5
  observeAsks<Payload = unknown>(observer: AskObserver<Payload>): Cleanup;
5
6
  }>;
6
7
  /**
@@ -0,0 +1,9 @@
1
+ import type { Process } from "@phreshos/client";
2
+ /** Mutable lifecycle state of one Process and its Endpoint incarnations. */
3
+ export type ProcessState = Readonly<{
4
+ exited: boolean;
5
+ serverExists: boolean;
6
+ clientExists: boolean;
7
+ }>;
8
+ /** Explicitly reads and follows one Process while this hook is mounted. */
9
+ export default function useProcessState(process: Process): ProcessState | undefined;
@@ -0,0 +1,23 @@
1
+ import { useMemo, useSyncExternalStore } from "react";
2
+ import LiveState, { combineCleanups } from "./live-state.js";
3
+ /** Explicitly reads and follows one Process while this hook is mounted. */
4
+ export default function useProcessState(process) {
5
+ const state = useMemo(() => new LiveState(async () => {
6
+ const [exited, serverExists, clientExists] = await Promise.all([
7
+ process.exited(),
8
+ process.server.exists(),
9
+ process.client.exists()
10
+ ]);
11
+ return { exited, serverExists, clientExists };
12
+ }, reduce => combineCleanups(process.subscribe("endpointStart", endpoint => reduce(current => endpointPresence(current, process, endpoint, true))), process.subscribe("endpointStop", endpoint => reduce(current => endpointPresence(current, process, endpoint, false))), process.subscribe("exit", () => reduce(current => ({ ...current, exited: true, serverExists: false, clientExists: false }))))), [process]);
13
+ return useSyncExternalStore(state.subscribe, state.snapshot, state.snapshot);
14
+ }
15
+ function endpointPresence(state, process, endpoint, exists) {
16
+ if (endpoint === process.server) {
17
+ return state.serverExists === exists ? state : { ...state, serverExists: exists };
18
+ }
19
+ if (endpoint === process.client) {
20
+ return state.clientExists === exists ? state : { ...state, clientExists: exists };
21
+ }
22
+ return state;
23
+ }
@@ -0,0 +1,8 @@
1
+ import type { Process, Program } from "@phreshos/client";
2
+ /** Mutable runtime state derived from one Program's reads and live events. */
3
+ export type ProgramState = Readonly<{
4
+ installed: boolean;
5
+ processes: readonly Process[];
6
+ }>;
7
+ /** Explicitly reads and follows one Program while this hook is mounted. */
8
+ export default function useProgramState(program: Program): ProgramState | undefined;
@@ -0,0 +1,22 @@
1
+ import { useMemo, useSyncExternalStore } from "react";
2
+ import LiveState, { combineCleanups } from "./live-state.js";
3
+ /** Explicitly reads and follows one Program while this hook is mounted. */
4
+ export default function useProgramState(program) {
5
+ const state = useMemo(() => new LiveState(async () => {
6
+ const [installed, processes] = await Promise.all([
7
+ program.installed(),
8
+ program.processes()
9
+ ]);
10
+ return { installed, processes };
11
+ }, reduce => combineCleanups(program.subscribe("processCreate", process => reduce(current => addProcess(current, process))), program.subscribe("processExit", ({ process }) => reduce(current => removeProcess(current, process))), program.subscribe("uninstall", () => reduce(current => current.installed ? { ...current, installed: false } : current)))), [program]);
12
+ return useSyncExternalStore(state.subscribe, state.snapshot, state.snapshot);
13
+ }
14
+ function addProcess(state, process) {
15
+ if (state.processes.includes(process))
16
+ return state;
17
+ return { ...state, processes: [...state.processes, process] };
18
+ }
19
+ function removeProcess(state, process) {
20
+ const processes = state.processes.filter(candidate => candidate !== process);
21
+ return processes.length === state.processes.length ? state : { ...state, processes };
22
+ }
@@ -0,0 +1,3 @@
1
+ import { type NumericScale } from "@phreshos/core";
2
+ /** Returns the complete visual scale derived from one explicit numeric value. */
3
+ export default function useScale(value: number): NumericScale;
@@ -0,0 +1,6 @@
1
+ import { useMemo } from "react";
2
+ import { numericScale } from "@phreshos/core";
3
+ /** Returns the complete visual scale derived from one explicit numeric value. */
4
+ export default function useScale(value) {
5
+ return useMemo(() => numericScale(value), [value]);
6
+ }
@@ -1,4 +1,4 @@
1
- import type { EventMessage, EventName, Subscribable } from "@phreshos/client";
1
+ import type { Channel, ChannelMessage, Endpoint, EventMessage, EventName, Subscribable, SubscribableEvents, SubscribableFallback } from "@phreshos/client";
2
2
  type OpenEvent = string & {};
3
3
  type AvailableEvent<Events extends object, Fallback> = EventName<Events> | ([Fallback] extends [never] ? never : OpenEvent);
4
4
  type CompatibleEvent<Events extends object, Fallback, Narrowed> = {
@@ -6,12 +6,22 @@ type CompatibleEvent<Events extends object, Fallback, Narrowed> = {
6
6
  }[EventName<Events>] | ([
7
7
  Fallback
8
8
  ] extends [never] ? never : Narrowed extends Fallback ? OpenEvent : never);
9
+ type SubscribableTarget = Readonly<{
10
+ subscribe: unknown;
11
+ }>;
12
+ type TargetEvent<Target> = AvailableEvent<SubscribableEvents<Target>, SubscribableFallback<Target>>;
13
+ type TargetMessage<Target, Event extends string> = EventMessage<SubscribableEvents<Target>, SubscribableFallback<Target>, Event>;
9
14
  /**
10
- * Subscribes while mounted and retains the latest projected result.
15
+ * Subscribes while mounted and retains the latest message or projected result.
11
16
  *
12
17
  * The target supplies known message types. An explicit generic or callback
13
18
  * annotation may narrow that type, but cannot replace it incompatibly.
14
19
  */
20
+ export default function useSubscribe<Target extends SubscribableTarget, Event extends TargetEvent<Target>>(target: Target, event: Event): TargetMessage<Target, Event> | undefined;
21
+ export default function useSubscribe<Narrowed = unknown>(target: Endpoint, event: string): Narrowed | undefined;
22
+ export default function useSubscribe<Narrowed extends ChannelMessage<unknown> = ChannelMessage<unknown>>(target: Channel, event: string): Narrowed | undefined;
23
+ export default function useSubscribe<Target extends SubscribableTarget, Event extends TargetEvent<Target>, Result>(target: Target, event: Event, project: (message: TargetMessage<Target, Event>) => Result): Awaited<Result> | undefined;
24
+ export default function useSubscribe<Narrowed, Result = unknown>(target: Endpoint, event: string, project: (message: Narrowed) => Result): Awaited<Result> | undefined;
25
+ export default function useSubscribe<Narrowed extends ChannelMessage<unknown>, Result = unknown>(target: Channel, event: string, project: (message: Narrowed) => Result): Awaited<Result> | undefined;
15
26
  export default function useSubscribe<Narrowed, Events extends object, Fallback, Result>(target: Subscribable<Events, Fallback>, event: CompatibleEvent<Events, Fallback, Narrowed>, project: (message: Narrowed) => Result): Awaited<Result> | undefined;
16
- export default function useSubscribe<Events extends object, Fallback, Event extends AvailableEvent<Events, Fallback>, Result>(target: Subscribable<Events, Fallback>, event: Event, project: (message: EventMessage<Events, Fallback, Event>) => Result): Awaited<Result> | undefined;
17
27
  export {};
@@ -5,5 +5,8 @@ export default function useSubscribe(target, event, project) {
5
5
  const subscribe = target.subscribe;
6
6
  return subscribe(event, receive);
7
7
  }, [target, event]);
8
- return useEventResult(connect, project);
8
+ return useEventResult(connect, project ?? identity);
9
+ }
10
+ function identity(message) {
11
+ return message;
9
12
  }
@@ -0,0 +1,3 @@
1
+ import type { Window, WindowState } from "@phreshos/client";
2
+ /** Explicitly reads and follows one live Client Window while mounted. */
3
+ export default function useWindowState(window: Window): WindowState | undefined;
@@ -0,0 +1,18 @@
1
+ import { useMemo, useSyncExternalStore } from "react";
2
+ import LiveState, { combineCleanups } from "./live-state.js";
3
+ /** Explicitly reads and follows one live Client Window while mounted. */
4
+ export default function useWindowState(window) {
5
+ const state = useMemo(() => new LiveState(async () => {
6
+ const [title, position, size, minimized, front, layer, location] = await Promise.all([
7
+ window.title(),
8
+ window.position(),
9
+ window.size(),
10
+ window.minimized(),
11
+ window.front(),
12
+ window.layer(),
13
+ window.location()
14
+ ]);
15
+ return { title, position, size, minimized, front, layer, location };
16
+ }, reduce => combineCleanups(window.subscribe("move", position => reduce(current => ({ ...current, position }))), window.subscribe("resize", size => reduce(current => ({ ...current, size }))), window.subscribe("minimize", minimized => reduce(current => ({ ...current, minimized }))), window.subscribe("changeTitle", title => reduce(current => ({ ...current, title }))), window.subscribe("front", front => reduce(current => ({ ...current, front }))))), [window]);
17
+ return useSyncExternalStore(state.subscribe, state.snapshot, state.snapshot);
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phreshos/react",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "React adapters for the Client SDK.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -13,21 +13,54 @@
13
13
  },
14
14
  "files": [
15
15
  "dist",
16
+ "LICENSE",
16
17
  "README.md"
17
18
  ],
19
+ "author": "Zohayr SLILEH",
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/PhreshOS/react.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/PhreshOS/react/issues"
27
+ },
28
+ "homepage": "https://github.com/PhreshOS/react#readme",
29
+ "keywords": [
30
+ "phreshos",
31
+ "react",
32
+ "sdk",
33
+ "typescript"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "provenance": true
38
+ },
39
+ "packageManager": "bun@1.3.14",
18
40
  "scripts": {
19
- "check": "tsc --noEmit",
20
- "build": "tsc --noEmit false --outDir dist --rootDir source",
21
- "prepack": "node --run build"
41
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
42
+ "check": "tsc --noEmit && tsc -p tsconfig.test.json",
43
+ "test": "vitest run --environment jsdom",
44
+ "build": "node --run clean && tsc --noEmit false --outDir dist --rootDir source",
45
+ "verify:package": "node scripts/verify-package.mjs",
46
+ "verify": "node --run check && node --run test && node --run build && node --run verify:package",
47
+ "prepack": "node --run test && node --run build"
22
48
  },
23
49
  "peerDependencies": {
24
- "@phreshos/client": "^0.1.0",
50
+ "@phreshos/client": "^0.1.2",
51
+ "@phreshos/core": "^0.1.2",
25
52
  "react": "^19.2.0"
26
53
  },
27
54
  "devDependencies": {
28
- "@phreshos/client": "0.1.0",
55
+ "@phreshos/client": "^0.1.2",
56
+ "@phreshos/core": "^0.1.2",
57
+ "@testing-library/react": "^16.3.0",
29
58
  "@types/react": "^19.2.18",
59
+ "@types/react-dom": "^19.2.4",
60
+ "jsdom": "^30.0.1",
30
61
  "react": "^19.2.8",
31
- "typescript": "^6.0.3"
62
+ "react-dom": "^19.2.8",
63
+ "typescript": "^6.0.3",
64
+ "vitest": "^4.1.1"
32
65
  }
33
66
  }