@interlucent/pixel-stream-react 0.0.3 → 0.0.81

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/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @interlucent/pixel-stream-react
2
+
3
+ React bindings for the `@interlucent/pixel-stream` web component.
4
+
5
+ For documentation, visit [interlucent.ai](https://interlucent.ai).
package/dist/index.d.ts CHANGED
@@ -4,10 +4,11 @@
4
4
  * SSR-safe: renders an empty <pixel-stream> tag on the server, hydrates
5
5
  * on the client by lazily importing the web component definition.
6
6
  *
7
- * Provides:
8
- * - A <PixelStream> React component with typed props and event callbacks
9
- * - JSX IntrinsicElements augmentation so bare <pixel-stream> also type-checks
10
- * - Re-exports of key types from @interlucent/pixel-stream
7
+ * Follows the @lit/react `createComponent` pattern:
8
+ * - Properties set directly on the element (not via setAttribute)
9
+ * - useLayoutEffect for synchronous prop sync (before paint)
10
+ * - Stable EventListener-interface objects (one addEventListener per event, ever)
11
+ * - Previous-props tracking for clean removal
11
12
  */
12
13
  import React, { type RefObject } from 'react';
13
14
  import type { PixelStream as PixelStreamElement, PixelStreamStatus, ResolutionClampName, StreamResolutionClamp } from '@interlucent/pixel-stream';
@@ -141,10 +142,11 @@ export interface PixelStreamProps extends PixelStreamAttributeProps, PixelStream
141
142
  /**
142
143
  * React component wrapping the `<pixel-stream>` web component.
143
144
  *
144
- * - SSR-safe: renders `<pixel-stream>` on the server (no crash), hydrates on client
145
- * - Full typed props for all 51 HTML attributes
146
- * - Event callbacks for all custom events (`onStatusChange`, `onReady`, etc.)
147
- * - Ref forwarding access the underlying element for imperative methods
145
+ * Uses the same patterns as `@lit/react` / Stencil output targets:
146
+ * - Properties set directly on the element (preserves types)
147
+ * - `useLayoutEffect` for synchronous prop sync (no flash)
148
+ * - Stable `EventListener` interface objects (one `addEventListener` per event, ever)
149
+ * - Previous-props `Map` tracking for efficient diffing
148
150
  *
149
151
  * @example
150
152
  * ```tsx
@@ -158,7 +160,6 @@ export interface PixelStreamProps extends PixelStreamAttributeProps, PixelStream
158
160
  * ref={ref}
159
161
  * admissionToken="tok_..."
160
162
  * onStatusChange={(e) => console.log(e.detail.newStatus)}
161
- * onReady={() => console.log('ready')}
162
163
  * style={{ width: '100%', height: '100%' }}
163
164
  * />
164
165
  * );
package/dist/index.js CHANGED
@@ -1,17 +1,21 @@
1
+ 'use client';
1
2
  /**
2
3
  * React bindings for the <pixel-stream> web component.
3
4
  *
4
5
  * SSR-safe: renders an empty <pixel-stream> tag on the server, hydrates
5
6
  * on the client by lazily importing the web component definition.
6
7
  *
7
- * Provides:
8
- * - A <PixelStream> React component with typed props and event callbacks
9
- * - JSX IntrinsicElements augmentation so bare <pixel-stream> also type-checks
10
- * - Re-exports of key types from @interlucent/pixel-stream
8
+ * Follows the @lit/react `createComponent` pattern:
9
+ * - Properties set directly on the element (not via setAttribute)
10
+ * - useLayoutEffect for synchronous prop sync (before paint)
11
+ * - Stable EventListener-interface objects (one addEventListener per event, ever)
12
+ * - Previous-props tracking for clean removal
11
13
  */
12
- import React, { useRef, useEffect, useState, useImperativeHandle, forwardRef, } from 'react';
14
+ import React, { useRef, useEffect, useLayoutEffect, useState, useCallback, forwardRef, } from 'react';
13
15
  // ===== SSR guard =====
14
16
  const isBrowser = typeof window !== 'undefined';
17
+ // useLayoutEffect warns in SSR — swap to useEffect on the server (which is a no-op anyway)
18
+ const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;
15
19
  // Lazy web-component registration — called once on first mount
16
20
  let _registered = false;
17
21
  function ensureRegistered() {
@@ -58,61 +62,66 @@ const EVENT_MAP = {
58
62
  onXRStreamDetected: 'xr-stream-detected',
59
63
  onLog: 'interlucent:log',
60
64
  };
61
- // ===== Mapping from React prop to HTML attribute =====
62
- const ATTR_MAP = {
63
- admissionToken: 'admission-token',
64
- appId: 'app-id',
65
- appVersion: 'app-version',
66
- noAutoConnect: 'no-auto-connect',
67
- nativeTouch: 'native-touch',
68
- pointerLock: 'pointer-lock',
69
- pointerLockRelease: 'pointer-lock-release',
70
- suppressBrowserKeys: 'suppress-browser-keys',
65
+ // ===== Mapping from React camelCase prop to element property name =====
66
+ // These are set directly on the element via `el[prop] = value` (not setAttribute).
67
+ const PROP_MAP = {
68
+ admissionToken: 'admissionToken',
69
+ appId: 'appId',
70
+ appVersion: 'appVersion',
71
+ noAutoConnect: 'noAutoConnect',
72
+ nativeTouch: 'nativeTouch',
73
+ pointerLock: 'pointerLock',
74
+ pointerLockRelease: 'pointerLockRelease',
75
+ suppressBrowserKeys: 'suppressBrowserKeys',
76
+ mute: 'mute',
77
+ volume: 'volume',
78
+ rendezvousPreference: 'rendezvousPreference',
79
+ lingerPreference: 'lingerPreference',
80
+ leftGracePeriod: 'leftGracePeriod',
81
+ apiEndpoint: 'apiEndpoint',
82
+ reconnectMode: 'reconnectMode',
83
+ reconnectAttempts: 'reconnectAttempts',
84
+ reconnectStrategy: 'reconnectStrategy',
85
+ reconnectInterval: 'reconnectInterval',
86
+ disconnectGraceMs: 'disconnectGraceMs',
87
+ resizeMode: 'resizeMode',
88
+ dprCap: 'dprCap',
89
+ resolutionClamp: 'resolutionClamp',
90
+ debug: 'debug',
91
+ logLevel: 'logLevel',
92
+ iceBatchDelay: 'iceBatchDelay',
93
+ };
94
+ // Props that must go through setAttribute (no matching JS property on the element)
95
+ const ATTR_ONLY_MAP = {
71
96
  enableGamepad: 'enable-gamepad',
72
97
  enableXr: 'enable-xr',
73
98
  forceRelay: 'force-relay',
74
99
  queueWaitTolerance: 'queue-wait-tolerance',
75
100
  webrtcNegotiationTolerance: 'webrtc-negotiation-tolerance',
76
- reconnectMode: 'reconnect-mode',
77
- reconnectAttempts: 'reconnect-attempts',
78
- reconnectStrategy: 'reconnect-strategy',
79
- reconnectInterval: 'reconnect-interval',
80
- disconnectGraceMs: 'disconnect-grace-ms',
81
- resizeMode: 'resize-mode',
82
- dprCap: 'dpr-cap',
83
- resolutionClamp: 'resolution-clamp',
101
+ controls: 'controls',
84
102
  swiftJobRequest: 'swift-job-request',
85
103
  streamerId: 'streamer-id',
104
+ lat: 'lat',
105
+ lng: 'lng',
86
106
  streamQuality: 'stream-quality',
87
107
  videoBitrateMin: 'video-bitrate-min',
88
108
  videoBitrateStart: 'video-bitrate-start',
89
109
  videoBitrateMax: 'video-bitrate-max',
90
- logLevel: 'log-level',
91
- rendezvousPreference: 'rendezvous-preference',
92
- lingerPreference: 'linger-preference',
93
- leftGracePeriod: 'left-grace-period',
94
- apiEndpoint: 'api-endpoint',
95
110
  };
96
- // Props that are boolean HTML attributes (presence = true, absence = false)
111
+ // Boolean attributes (for ATTR_ONLY props presence = true, absence = false)
97
112
  const BOOLEAN_ATTRS = new Set([
98
- 'noAutoConnect', 'nativeTouch', 'pointerLock', 'pointerLockRelease',
99
- 'suppressBrowserKeys', 'enableGamepad', 'enableXr', 'mute', 'debug',
100
- 'controls', 'forceRelay', 'swiftJobRequest',
101
- ]);
102
- // All known attribute prop names (for removal tracking)
103
- const ALL_ATTR_NAMES = new Set([
104
- ...Object.keys(ATTR_MAP),
105
- 'mute', 'volume', 'debug', 'controls', 'lat', 'lng',
113
+ 'enableGamepad', 'enableXr', 'forceRelay', 'controls', 'swiftJobRequest',
106
114
  ]);
107
- // Props handled separately (not attributes or events)
108
- const SPECIAL_PROPS = new Set(['className', 'style', 'children']);
115
+ // Props handled by React directly
116
+ const REACT_PROPS = new Set(['className', 'style', 'children']);
109
117
  /**
110
118
  * React component wrapping the `<pixel-stream>` web component.
111
119
  *
112
- * - SSR-safe: renders `<pixel-stream>` on the server (no crash), hydrates on client
113
- * - Full typed props for all 51 HTML attributes
114
- * - Event callbacks for all custom events (`onStatusChange`, `onReady`, etc.)
115
- * - Ref forwarding access the underlying element for imperative methods
120
+ * Uses the same patterns as `@lit/react` / Stencil output targets:
121
+ * - Properties set directly on the element (preserves types)
122
+ * - `useLayoutEffect` for synchronous prop sync (no flash)
123
+ * - Stable `EventListener` interface objects (one `addEventListener` per event, ever)
124
+ * - Previous-props `Map` tracking for efficient diffing
116
125
  *
117
126
  * @example
118
127
  * ```tsx
@@ -126,101 +135,140 @@ const SPECIAL_PROPS = new Set(['className', 'style', 'children']);
126
135
  * ref={ref}
127
136
  * admissionToken="tok_..."
128
137
  * onStatusChange={(e) => console.log(e.detail.newStatus)}
129
- * onReady={() => console.log('ready')}
130
138
  * style={{ width: '100%', height: '100%' }}
131
139
  * />
132
140
  * );
133
141
  * }
134
142
  * ```
135
143
  */
136
- export const PixelStream = forwardRef(function PixelStream(props, ref) {
144
+ export const PixelStream = /*@__PURE__*/ forwardRef(function PixelStream(props, ref) {
137
145
  const elementRef = useRef(null);
138
- const [mounted, setMounted] = useState(false);
139
- // Expose the raw element to the parent ref
140
- useImperativeHandle(ref, () => elementRef.current, [mounted]);
146
+ const prevPropsRef = useRef(new Map());
147
+ // Stable EventListener-interface objects: { handleEvent: fn }
148
+ // One addEventListener per event name, ever. On re-render we just swap handleEvent.
149
+ const eventHandlersRef = useRef(new Map());
150
+ // Merge internal + forwarded ref (Lit pattern)
151
+ const mergedRef = useCallback((node) => {
152
+ elementRef.current = node;
153
+ if (typeof ref === 'function') {
154
+ ref(node);
155
+ }
156
+ else if (ref && typeof ref === 'object') {
157
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
158
+ ref.current = node;
159
+ }
160
+ }, [ref]);
141
161
  // Register the web component on first client mount
142
162
  useEffect(() => {
143
163
  ensureRegistered();
144
- setMounted(true);
145
164
  }, []);
146
- // Sync custom event listeners.
147
- // We store the latest props in a ref so the effect doesn't need to
148
- // teardown/re-add listeners on every render — only when the set of
149
- // subscribed event names changes.
150
- const propsRef = useRef(props);
151
- propsRef.current = props;
152
- // Compute which event names are currently subscribed
153
- const activeEvents = Object.keys(EVENT_MAP)
154
- .filter(k => typeof props[k] === 'function')
155
- .join(',');
156
- useEffect(() => {
157
- const el = elementRef.current;
158
- if (!el || !mounted)
159
- return;
160
- const listeners = [];
161
- for (const [propName, eventName] of Object.entries(EVENT_MAP)) {
162
- const handler = propsRef.current[propName];
163
- if (typeof handler !== 'function')
164
- continue;
165
- // Wrap so we always call the latest prop version
166
- const wrapped = (e) => {
167
- const fn = propsRef.current[propName];
168
- if (typeof fn === 'function')
169
- fn(e);
170
- };
171
- el.addEventListener(eventName, wrapped);
172
- listeners.push([eventName, wrapped]);
173
- }
174
- return () => {
175
- for (const [eventName, handler] of listeners) {
176
- el.removeEventListener(eventName, handler);
177
- }
178
- };
179
- // eslint-disable-next-line react-hooks/exhaustive-deps
180
- }, [mounted, activeEvents]);
181
- // Sync HTML attributes from props
182
- useEffect(() => {
165
+ // Sync properties + attributes + events (synchronous, before paint)
166
+ useIsomorphicLayoutEffect(() => {
183
167
  const el = elementRef.current;
184
- if (!el || !mounted)
168
+ if (!el)
185
169
  return;
186
- // Build desired attribute state
187
- const desired = new Map();
170
+ const newProps = new Map();
188
171
  for (const [propName, value] of Object.entries(props)) {
189
- if (SPECIAL_PROPS.has(propName) || propName in EVENT_MAP)
172
+ if (REACT_PROPS.has(propName))
190
173
  continue;
191
- if (!ALL_ATTR_NAMES.has(propName))
174
+ // --- Events ---
175
+ const eventName = EVENT_MAP[propName];
176
+ if (eventName !== undefined) {
177
+ let handler = eventHandlersRef.current.get(eventName);
178
+ if (typeof value === 'function') {
179
+ if (handler === undefined) {
180
+ // First time: create stable handler object, one addEventListener
181
+ handler = { handleEvent: value };
182
+ eventHandlersRef.current.set(eventName, handler);
183
+ el.addEventListener(eventName, handler);
184
+ }
185
+ else {
186
+ // Subsequent: just swap the function pointer
187
+ handler.handleEvent = value;
188
+ }
189
+ }
190
+ else if (handler !== undefined) {
191
+ // Callback removed — tear down
192
+ el.removeEventListener(eventName, handler);
193
+ eventHandlersRef.current.delete(eventName);
194
+ }
192
195
  continue;
193
- const attrName = ATTR_MAP[propName] ?? propName;
194
- if (BOOLEAN_ATTRS.has(propName)) {
195
- if (value)
196
- desired.set(attrName, '');
197
196
  }
198
- else if (value != null) {
199
- desired.set(attrName, String(value));
197
+ // --- Direct properties ---
198
+ const elProp = PROP_MAP[propName];
199
+ if (elProp !== undefined) {
200
+ const prev = prevPropsRef.current.get(propName);
201
+ if (value !== prev) {
202
+ el[elProp] = value;
203
+ }
204
+ newProps.set(propName, value);
205
+ continue;
200
206
  }
201
- }
202
- // Set desired attributes
203
- for (const [attr, value] of desired) {
204
- if (el.getAttribute(attr) !== value) {
205
- el.setAttribute(attr, value);
207
+ // --- Attribute-only props ---
208
+ const attrName = ATTR_ONLY_MAP[propName];
209
+ if (attrName !== undefined) {
210
+ const prev = prevPropsRef.current.get(propName);
211
+ if (value !== prev) {
212
+ if (BOOLEAN_ATTRS.has(propName)) {
213
+ if (value) {
214
+ el.setAttribute(attrName, '');
215
+ }
216
+ else {
217
+ el.removeAttribute(attrName);
218
+ }
219
+ }
220
+ else if (value != null) {
221
+ el.setAttribute(attrName, String(value));
222
+ }
223
+ else {
224
+ el.removeAttribute(attrName);
225
+ }
226
+ }
227
+ newProps.set(propName, value);
228
+ continue;
206
229
  }
207
230
  }
208
- // Remove attributes that are no longer in props
209
- const allKnownAttrNames = [
210
- ...Object.values(ATTR_MAP),
211
- 'mute', 'volume', 'debug', 'controls', 'lat', 'lng',
212
- ];
213
- for (const attrName of allKnownAttrNames) {
214
- if (!desired.has(attrName) && el.hasAttribute(attrName)) {
231
+ // Unset any props that were present last render but are gone now
232
+ for (const [propName, prevValue] of prevPropsRef.current) {
233
+ if (newProps.has(propName))
234
+ continue;
235
+ const elProp = PROP_MAP[propName];
236
+ if (elProp !== undefined) {
237
+ el[elProp] = undefined;
238
+ continue;
239
+ }
240
+ const attrName = ATTR_ONLY_MAP[propName];
241
+ if (attrName !== undefined) {
215
242
  el.removeAttribute(attrName);
243
+ continue;
244
+ }
245
+ // Removed event callback
246
+ const eventName = EVENT_MAP[propName];
247
+ if (eventName !== undefined && prevValue !== undefined) {
248
+ const handler = eventHandlersRef.current.get(eventName);
249
+ if (handler) {
250
+ el.removeEventListener(eventName, handler);
251
+ eventHandlersRef.current.delete(eventName);
252
+ }
216
253
  }
217
254
  }
255
+ prevPropsRef.current = newProps;
218
256
  });
219
- // On the server, render a bare <pixel-stream> tag.
220
- // On the client, also render <pixel-stream> and let effects handle attrs/events.
257
+ // Clean up all event listeners on unmount
258
+ useEffect(() => {
259
+ return () => {
260
+ const el = elementRef.current;
261
+ if (!el)
262
+ return;
263
+ for (const [eventName, handler] of eventHandlersRef.current) {
264
+ el.removeEventListener(eventName, handler);
265
+ }
266
+ eventHandlersRef.current.clear();
267
+ };
268
+ }, []);
221
269
  return React.createElement('pixel-stream', {
222
- ref: elementRef,
223
- className: props.className,
270
+ ref: mergedRef,
271
+ class: props.className,
224
272
  style: props.style,
225
273
  suppressHydrationWarning: true,
226
274
  }, props.children);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interlucent/pixel-stream-react",
3
- "version": "0.0.3",
3
+ "version": "0.0.81",
4
4
  "description": "React bindings for the @interlucent/pixel-stream web component",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,18 +18,15 @@
18
18
  "dist",
19
19
  "README.md"
20
20
  ],
21
- "scripts": {
22
- "build": "tsc -p tsconfig.build.json"
23
- },
24
21
  "peerDependencies": {
25
22
  "@interlucent/pixel-stream": ">=0.0.79",
26
23
  "react": ">=18.0.0"
27
24
  },
28
25
  "devDependencies": {
29
- "@interlucent/pixel-stream": "workspace:*",
30
26
  "@types/react": "^18.0.0",
31
27
  "react": "^18.0.0",
32
- "typescript": "^5.3.0"
28
+ "typescript": "^5.3.0",
29
+ "@interlucent/pixel-stream": "0.0.82"
33
30
  },
34
31
  "keywords": [
35
32
  "pixel-streaming",
@@ -42,5 +39,8 @@
42
39
  "license": "UNLICENSED",
43
40
  "publishConfig": {
44
41
  "access": "public"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.build.json"
45
45
  }
46
- }
46
+ }