react-realtime-hooks 1.4.0 → 1.4.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/README.md +14 -0
- package/dist/index.cjs +126 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +127 -59
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,7 @@ Real apps need:
|
|
|
38
38
|
- Discriminated connection snapshots: `idle`, `connecting`, `open`, `reconnecting`, `closing`, `closed`, `error`.
|
|
39
39
|
- First-class TypeScript support with generic message types and custom parsers/serializers.
|
|
40
40
|
- SSR-safe by default. No browser-only globals are touched during server render.
|
|
41
|
+
- Strict Mode safe. Works correctly under React 18+ `<StrictMode>` and Next.js dev double-mount — see [Strict Mode Safety](#strict-mode-safety).
|
|
41
42
|
- Zero runtime dependencies beyond React.
|
|
42
43
|
- Manual controls stay available when you need them: `open()`, `close()`, `reconnect()`, `send()`.
|
|
43
44
|
|
|
@@ -655,6 +656,19 @@ That keeps the gate predictable when multiple blockers apply at once.
|
|
|
655
656
|
- No transport polyfills are bundled. Provide your own runtime support where needed.
|
|
656
657
|
- Browser-native transport constraints still apply: auth, proxy, CORS, and network policy are outside the hook's control.
|
|
657
658
|
|
|
659
|
+
## Strict Mode Safety
|
|
660
|
+
|
|
661
|
+
All hooks are safe to use under React 18+ `<React.StrictMode>` and the Next.js App Router's dev mode, both of which intentionally mount → unmount → mount each component on first render to surface effect-cleanup bugs.
|
|
662
|
+
|
|
663
|
+
Concretely:
|
|
664
|
+
|
|
665
|
+
- `useWebSocket` and `useEventSource` defer the actual `new WebSocket(...)` / `new EventSource(...)` call by a microtask. If the component unmounts before that microtask runs (the Strict Mode discard mount), no transport is ever created. If the mount survives, exactly one transport is created — never two.
|
|
666
|
+
- All effects in the library tear down their timers, listeners, and transports synchronously in the cleanup function. There is no "zombie" `setInterval`, no orphaned `addEventListener`, no leaked `WebSocket` left in `CONNECTING` after a discarded mount.
|
|
667
|
+
- Latest-state refs are committed via `useInsertionEffect`, not by writing to `ref.current` during render. A discarded render never leaves the ref out of sync with the committed tree.
|
|
668
|
+
- The library's own test suite runs every hook test inside `<React.StrictMode>` by default, so any regression that only shows up under double-mount is caught in CI.
|
|
669
|
+
|
|
670
|
+
If you observe a Strict Mode regression — e.g. two simultaneous WebSocket connections, an `EventSource` that survives a closed component, or a heartbeat that keeps firing after unmount — please open an issue with a minimal repro. That class of bug is supposed to be impossible by construction, and we treat it as a correctness defect.
|
|
671
|
+
|
|
658
672
|
## Testing And Quality
|
|
659
673
|
|
|
660
674
|
The package includes behavior tests for:
|
package/dist/index.cjs
CHANGED
|
@@ -466,7 +466,9 @@ var useReconnect = (options = {}) => {
|
|
|
466
466
|
() => createInitialState(normalizedOptions.enabled)
|
|
467
467
|
);
|
|
468
468
|
const stateRef = react.useRef(state);
|
|
469
|
-
|
|
469
|
+
react.useInsertionEffect(() => {
|
|
470
|
+
stateRef.current = state;
|
|
471
|
+
});
|
|
470
472
|
const commitState = (next) => {
|
|
471
473
|
const resolved = typeof next === "function" ? next(stateRef.current) : next;
|
|
472
474
|
stateRef.current = resolved;
|
|
@@ -599,7 +601,9 @@ var useHeartbeat = (options) => {
|
|
|
599
601
|
() => createInitialState2(enabled && startOnMount)
|
|
600
602
|
);
|
|
601
603
|
const stateRef = react.useRef(state);
|
|
602
|
-
|
|
604
|
+
react.useInsertionEffect(() => {
|
|
605
|
+
stateRef.current = state;
|
|
606
|
+
});
|
|
603
607
|
const commitState = (next) => {
|
|
604
608
|
const resolved = typeof next === "function" ? next(stateRef.current) : next;
|
|
605
609
|
stateRef.current = resolved;
|
|
@@ -805,9 +809,95 @@ var resolveUrlProvider = (url) => {
|
|
|
805
809
|
const resolved = typeof url === "function" ? url() : url;
|
|
806
810
|
return normalizeResolvedUrl(resolved ?? null);
|
|
807
811
|
};
|
|
812
|
+
var createInitialState3 = () => ({
|
|
813
|
+
manualClose: false,
|
|
814
|
+
manualOpen: false,
|
|
815
|
+
pendingCloseAction: null,
|
|
816
|
+
reconnectSuppressed: false,
|
|
817
|
+
skipNextCloseReconnect: false,
|
|
818
|
+
terminalError: null
|
|
819
|
+
});
|
|
820
|
+
var useWebSocketController = () => {
|
|
821
|
+
const ref = react.useRef(null);
|
|
822
|
+
if (ref.current === null) {
|
|
823
|
+
ref.current = createInitialState3();
|
|
824
|
+
}
|
|
825
|
+
const state = ref.current;
|
|
826
|
+
const controllerRef = react.useRef(null);
|
|
827
|
+
if (controllerRef.current === null) {
|
|
828
|
+
controllerRef.current = {
|
|
829
|
+
clearReconnectSuppression: () => {
|
|
830
|
+
state.reconnectSuppressed = false;
|
|
831
|
+
},
|
|
832
|
+
consumePendingCloseAction: () => {
|
|
833
|
+
const action = state.pendingCloseAction;
|
|
834
|
+
state.pendingCloseAction = null;
|
|
835
|
+
return action;
|
|
836
|
+
},
|
|
837
|
+
consumeSkipNextCloseReconnect: () => {
|
|
838
|
+
const skip = state.skipNextCloseReconnect;
|
|
839
|
+
state.skipNextCloseReconnect = false;
|
|
840
|
+
return skip;
|
|
841
|
+
},
|
|
842
|
+
hasManualCloseRequested: () => state.manualClose,
|
|
843
|
+
hasManualOpenRequested: () => state.manualOpen,
|
|
844
|
+
isReconnectSuppressed: () => state.reconnectSuppressed,
|
|
845
|
+
noteEffectInitiatedClose: () => {
|
|
846
|
+
state.reconnectSuppressed = true;
|
|
847
|
+
},
|
|
848
|
+
noteHeartbeatActiveSocketClose: (input) => {
|
|
849
|
+
state.manualOpen = false;
|
|
850
|
+
state.terminalError = input.reconnectTrigger === null ? input.error : null;
|
|
851
|
+
state.pendingCloseAction = input;
|
|
852
|
+
state.skipNextCloseReconnect = true;
|
|
853
|
+
state.reconnectSuppressed = true;
|
|
854
|
+
},
|
|
855
|
+
noteHeartbeatNoActiveSocket: ({ shouldReconnect, error }) => {
|
|
856
|
+
state.manualOpen = false;
|
|
857
|
+
state.terminalError = shouldReconnect ? null : error;
|
|
858
|
+
},
|
|
859
|
+
noteParseError: (error) => {
|
|
860
|
+
state.terminalError = error;
|
|
861
|
+
state.manualOpen = false;
|
|
862
|
+
state.skipNextCloseReconnect = true;
|
|
863
|
+
state.reconnectSuppressed = true;
|
|
864
|
+
},
|
|
865
|
+
noteSocketOpened: () => {
|
|
866
|
+
state.manualClose = false;
|
|
867
|
+
state.manualOpen = false;
|
|
868
|
+
state.reconnectSuppressed = false;
|
|
869
|
+
state.terminalError = null;
|
|
870
|
+
},
|
|
871
|
+
noteUserCloseRequested: () => {
|
|
872
|
+
state.manualClose = true;
|
|
873
|
+
state.manualOpen = false;
|
|
874
|
+
state.reconnectSuppressed = true;
|
|
875
|
+
state.terminalError = null;
|
|
876
|
+
},
|
|
877
|
+
noteUserOpenRequested: () => {
|
|
878
|
+
state.manualClose = false;
|
|
879
|
+
state.manualOpen = true;
|
|
880
|
+
state.reconnectSuppressed = false;
|
|
881
|
+
state.terminalError = null;
|
|
882
|
+
},
|
|
883
|
+
noteUserReconnectClosed: () => {
|
|
884
|
+
state.reconnectSuppressed = false;
|
|
885
|
+
},
|
|
886
|
+
noteUserReconnectRequested: () => {
|
|
887
|
+
state.manualClose = false;
|
|
888
|
+
state.manualOpen = true;
|
|
889
|
+
state.skipNextCloseReconnect = true;
|
|
890
|
+
state.reconnectSuppressed = true;
|
|
891
|
+
state.terminalError = null;
|
|
892
|
+
},
|
|
893
|
+
peekTerminalError: () => state.terminalError
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
return controllerRef.current;
|
|
897
|
+
};
|
|
808
898
|
|
|
809
899
|
// src/hooks/useWebSocket.ts
|
|
810
|
-
var
|
|
900
|
+
var createInitialState4 = (status = "idle") => ({
|
|
811
901
|
bufferedAmount: 0,
|
|
812
902
|
lastChangedAt: null,
|
|
813
903
|
lastCloseEvent: null,
|
|
@@ -850,18 +940,15 @@ var useWebSocket = (options) => {
|
|
|
850
940
|
const activeSocketEpochRef = react.useRef(null);
|
|
851
941
|
const closingSocketEpochRef = react.useRef(null);
|
|
852
942
|
const nextSocketEpochRef = react.useRef(0);
|
|
853
|
-
const
|
|
854
|
-
const manualOpenRef = react.useRef(false);
|
|
855
|
-
const skipCloseReconnectRef = react.useRef(false);
|
|
856
|
-
const suppressReconnectRef = react.useRef(false);
|
|
857
|
-
const pendingCloseActionRef = react.useRef(null);
|
|
858
|
-
const terminalErrorRef = react.useRef(null);
|
|
943
|
+
const controller = useWebSocketController();
|
|
859
944
|
const [openNonce, setOpenNonce] = react.useState(0);
|
|
860
945
|
const [state, setState] = react.useState(
|
|
861
|
-
() =>
|
|
946
|
+
() => createInitialState4(connect ? "connecting" : "idle")
|
|
862
947
|
);
|
|
863
948
|
const stateRef = react.useRef(state);
|
|
864
|
-
|
|
949
|
+
react.useInsertionEffect(() => {
|
|
950
|
+
stateRef.current = state;
|
|
951
|
+
});
|
|
865
952
|
const reconnectEnabled = options.reconnect !== false && supported && resolvedUrl !== null;
|
|
866
953
|
const reconnect = useReconnect(
|
|
867
954
|
options.reconnect === false ? { enabled: false } : {
|
|
@@ -978,10 +1065,9 @@ var useWebSocket = (options) => {
|
|
|
978
1065
|
return;
|
|
979
1066
|
}
|
|
980
1067
|
const shouldReconnect = action === "reconnect" && reconnectEnabled && (options.shouldReconnect?.(error) ?? true);
|
|
981
|
-
manualOpenRef.current = false;
|
|
982
|
-
terminalErrorRef.current = shouldReconnect ? null : error;
|
|
983
1068
|
const socket2 = socketRef.current;
|
|
984
1069
|
if (socket2 === null || !isSocketActive(socket2)) {
|
|
1070
|
+
controller.noteHeartbeatNoActiveSocket({ error, shouldReconnect });
|
|
985
1071
|
commitState((current) => ({
|
|
986
1072
|
...current,
|
|
987
1073
|
lastChangedAt: Date.now(),
|
|
@@ -993,12 +1079,10 @@ var useWebSocket = (options) => {
|
|
|
993
1079
|
}
|
|
994
1080
|
return;
|
|
995
1081
|
}
|
|
996
|
-
|
|
1082
|
+
controller.noteHeartbeatActiveSocketClose({
|
|
997
1083
|
error,
|
|
998
1084
|
reconnectTrigger: shouldReconnect ? reconnectTrigger : null
|
|
999
|
-
};
|
|
1000
|
-
skipCloseReconnectRef.current = true;
|
|
1001
|
-
suppressReconnectRef.current = true;
|
|
1085
|
+
});
|
|
1002
1086
|
closeSocket({ trackClose: true });
|
|
1003
1087
|
}
|
|
1004
1088
|
);
|
|
@@ -1013,10 +1097,7 @@ var useWebSocket = (options) => {
|
|
|
1013
1097
|
}));
|
|
1014
1098
|
});
|
|
1015
1099
|
const handleOpen = useStableCallback((event, socket2) => {
|
|
1016
|
-
|
|
1017
|
-
manualOpenRef.current = false;
|
|
1018
|
-
suppressReconnectRef.current = false;
|
|
1019
|
-
terminalErrorRef.current = null;
|
|
1100
|
+
controller.noteSocketOpened();
|
|
1020
1101
|
reconnect.markConnected();
|
|
1021
1102
|
heartbeat.start();
|
|
1022
1103
|
commitState((current) => ({
|
|
@@ -1043,10 +1124,7 @@ var useWebSocket = (options) => {
|
|
|
1043
1124
|
cause: error,
|
|
1044
1125
|
kind: "parse-error"
|
|
1045
1126
|
});
|
|
1046
|
-
|
|
1047
|
-
manualOpenRef.current = false;
|
|
1048
|
-
skipCloseReconnectRef.current = true;
|
|
1049
|
-
suppressReconnectRef.current = true;
|
|
1127
|
+
controller.noteParseError(parseError);
|
|
1050
1128
|
reconnect.cancel();
|
|
1051
1129
|
heartbeat.stop();
|
|
1052
1130
|
options.onError?.(parseError);
|
|
@@ -1090,13 +1168,11 @@ var useWebSocket = (options) => {
|
|
|
1090
1168
|
}
|
|
1091
1169
|
heartbeat.stop();
|
|
1092
1170
|
updateBufferedAmount();
|
|
1093
|
-
const pendingCloseAction =
|
|
1094
|
-
|
|
1095
|
-
const
|
|
1096
|
-
const skipCloseReconnect = skipCloseReconnectRef.current;
|
|
1097
|
-
skipCloseReconnectRef.current = false;
|
|
1171
|
+
const pendingCloseAction = controller.consumePendingCloseAction();
|
|
1172
|
+
const terminalError = controller.peekTerminalError();
|
|
1173
|
+
const skipCloseReconnect = controller.consumeSkipNextCloseReconnect();
|
|
1098
1174
|
if (pendingCloseAction !== null) {
|
|
1099
|
-
|
|
1175
|
+
controller.clearReconnectSuppression();
|
|
1100
1176
|
commitState((current) => ({
|
|
1101
1177
|
...current,
|
|
1102
1178
|
lastChangedAt: Date.now(),
|
|
@@ -1111,7 +1187,7 @@ var useWebSocket = (options) => {
|
|
|
1111
1187
|
return;
|
|
1112
1188
|
}
|
|
1113
1189
|
if (terminalError !== null) {
|
|
1114
|
-
|
|
1190
|
+
controller.clearReconnectSuppression();
|
|
1115
1191
|
commitState((current) => ({
|
|
1116
1192
|
...current,
|
|
1117
1193
|
lastChangedAt: Date.now(),
|
|
@@ -1122,7 +1198,7 @@ var useWebSocket = (options) => {
|
|
|
1122
1198
|
options.onClose?.(event);
|
|
1123
1199
|
return;
|
|
1124
1200
|
}
|
|
1125
|
-
const shouldReconnect = !
|
|
1201
|
+
const shouldReconnect = !controller.isReconnectSuppressed() && !skipCloseReconnect && reconnectEnabled && (options.shouldReconnect?.(event) ?? true);
|
|
1126
1202
|
commitState((current) => ({
|
|
1127
1203
|
...current,
|
|
1128
1204
|
lastChangedAt: Date.now(),
|
|
@@ -1133,33 +1209,23 @@ var useWebSocket = (options) => {
|
|
|
1133
1209
|
if (shouldReconnect) {
|
|
1134
1210
|
reconnect.schedule("close");
|
|
1135
1211
|
} else {
|
|
1136
|
-
|
|
1212
|
+
controller.clearReconnectSuppression();
|
|
1137
1213
|
}
|
|
1138
1214
|
});
|
|
1139
1215
|
const open = useStableCallback(() => {
|
|
1140
|
-
|
|
1141
|
-
manualOpenRef.current = true;
|
|
1142
|
-
suppressReconnectRef.current = false;
|
|
1143
|
-
terminalErrorRef.current = null;
|
|
1216
|
+
controller.noteUserOpenRequested();
|
|
1144
1217
|
reconnect.cancel();
|
|
1145
1218
|
setOpenNonce((current) => current + 1);
|
|
1146
1219
|
});
|
|
1147
1220
|
const reconnectNow = useStableCallback(() => {
|
|
1148
|
-
|
|
1149
|
-
manualOpenRef.current = true;
|
|
1150
|
-
skipCloseReconnectRef.current = true;
|
|
1151
|
-
suppressReconnectRef.current = true;
|
|
1152
|
-
terminalErrorRef.current = null;
|
|
1221
|
+
controller.noteUserReconnectRequested();
|
|
1153
1222
|
heartbeat.stop();
|
|
1154
1223
|
closeSocket();
|
|
1155
|
-
|
|
1224
|
+
controller.noteUserReconnectClosed();
|
|
1156
1225
|
reconnect.schedule("manual");
|
|
1157
1226
|
});
|
|
1158
1227
|
const close = useStableCallback((code, reason) => {
|
|
1159
|
-
|
|
1160
|
-
manualOpenRef.current = false;
|
|
1161
|
-
suppressReconnectRef.current = true;
|
|
1162
|
-
terminalErrorRef.current = null;
|
|
1228
|
+
controller.noteUserCloseRequested();
|
|
1163
1229
|
reconnect.cancel();
|
|
1164
1230
|
heartbeat.stop();
|
|
1165
1231
|
commitState((current) => ({
|
|
@@ -1201,22 +1267,22 @@ var useWebSocket = (options) => {
|
|
|
1201
1267
|
}));
|
|
1202
1268
|
return;
|
|
1203
1269
|
}
|
|
1204
|
-
const shouldConnect =
|
|
1270
|
+
const shouldConnect = controller.peekTerminalError() === null && (connect && !controller.hasManualCloseRequested() || controller.hasManualOpenRequested() || reconnect.status === "running");
|
|
1205
1271
|
const nextSocketKey = `${resolvedUrl}::${protocolsDependency}::${options.binaryType ?? "blob"}`;
|
|
1206
1272
|
if (!shouldConnect) {
|
|
1207
1273
|
if (socketRef.current !== null) {
|
|
1208
|
-
|
|
1274
|
+
controller.noteEffectInitiatedClose();
|
|
1209
1275
|
closeSocket();
|
|
1210
1276
|
}
|
|
1211
1277
|
socketKeyRef.current = null;
|
|
1212
1278
|
commitState((current) => ({
|
|
1213
1279
|
...current,
|
|
1214
|
-
status:
|
|
1280
|
+
status: controller.peekTerminalError() !== null ? "error" : controller.hasManualCloseRequested() ? "closed" : "idle"
|
|
1215
1281
|
}));
|
|
1216
1282
|
return;
|
|
1217
1283
|
}
|
|
1218
1284
|
if (socketRef.current !== null && socketKeyRef.current !== nextSocketKey) {
|
|
1219
|
-
|
|
1285
|
+
controller.noteEffectInitiatedClose();
|
|
1220
1286
|
closeSocket();
|
|
1221
1287
|
}
|
|
1222
1288
|
if (socketRef.current !== null) {
|
|
@@ -1283,6 +1349,7 @@ var useWebSocket = (options) => {
|
|
|
1283
1349
|
closeSocket,
|
|
1284
1350
|
commitState,
|
|
1285
1351
|
connect,
|
|
1352
|
+
controller,
|
|
1286
1353
|
handleClose,
|
|
1287
1354
|
handleError,
|
|
1288
1355
|
handleMessage,
|
|
@@ -1297,11 +1364,10 @@ var useWebSocket = (options) => {
|
|
|
1297
1364
|
supported
|
|
1298
1365
|
]);
|
|
1299
1366
|
react.useEffect(() => () => {
|
|
1300
|
-
|
|
1367
|
+
controller.noteEffectInitiatedClose();
|
|
1301
1368
|
socketKeyRef.current = null;
|
|
1302
1369
|
activeSocketEpochRef.current = null;
|
|
1303
1370
|
closingSocketEpochRef.current = null;
|
|
1304
|
-
terminalErrorRef.current = null;
|
|
1305
1371
|
const socket2 = socketRef.current;
|
|
1306
1372
|
socketRef.current = null;
|
|
1307
1373
|
if (socket2 === null) {
|
|
@@ -1310,7 +1376,7 @@ var useWebSocket = (options) => {
|
|
|
1310
1376
|
if (isSocketActive(socket2)) {
|
|
1311
1377
|
socket2.close();
|
|
1312
1378
|
}
|
|
1313
|
-
}, []);
|
|
1379
|
+
}, [controller]);
|
|
1314
1380
|
const stopHeartbeat = heartbeat.stop;
|
|
1315
1381
|
react.useEffect(() => {
|
|
1316
1382
|
if (state.status !== "open") {
|
|
@@ -1415,7 +1481,7 @@ var useWebSocket = (options) => {
|
|
|
1415
1481
|
socket
|
|
1416
1482
|
};
|
|
1417
1483
|
};
|
|
1418
|
-
var
|
|
1484
|
+
var createInitialState5 = (status = "idle") => ({
|
|
1419
1485
|
lastChangedAt: null,
|
|
1420
1486
|
lastError: null,
|
|
1421
1487
|
lastEventName: null,
|
|
@@ -1452,10 +1518,12 @@ var useEventSource = (options) => {
|
|
|
1452
1518
|
const terminalErrorRef = react.useRef(null);
|
|
1453
1519
|
const [openNonce, setOpenNonce] = react.useState(0);
|
|
1454
1520
|
const [state, setState] = react.useState(
|
|
1455
|
-
() =>
|
|
1521
|
+
() => createInitialState5(connect ? "connecting" : "idle")
|
|
1456
1522
|
);
|
|
1457
1523
|
const stateRef = react.useRef(state);
|
|
1458
|
-
|
|
1524
|
+
react.useInsertionEffect(() => {
|
|
1525
|
+
stateRef.current = state;
|
|
1526
|
+
});
|
|
1459
1527
|
const reconnectEnabled = options.reconnect !== false && supported && resolvedUrl !== null;
|
|
1460
1528
|
const reconnect = useReconnect(
|
|
1461
1529
|
options.reconnect === false ? { enabled: false } : {
|